chore: merge main into next (#12351)

Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
Co-authored-by: Aditya Tripathi <aditya@climactic.co>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
github-actions[bot]
2026-10-27 22:25:43 +01:00
committed by GitHub
co-authored by Andras Bacsai Aditya Tripathi Claude Opus 5.5
parent fe6c73726d
commit 62b2a99a70
229 changed files with 6966 additions and 1042 deletions
+5 -1
View File
@@ -30,11 +30,15 @@
- In browser tests, address `x-forms.input` fields by `name` (`type('fieldName', ...)`); its HTML `id` is not always the Livewire property, so `#property` waits forever.
- For load tests against a dev VM, use your own instance (`COOLIFY_DEV_INSTANCE=<name> COOLIFY_DEV_SIDE_BY_SIDE=1 ./scripts/dev start`); other sessions can reseed the localhost VM of a shared instance. The 1 GiB VM drops packets when its conntrack table is full (`dmesg`: `nf_conntrack: table full`), so raise `net.netfilter.nf_conntrack_max` on it and run an idle load first to prove a clean baseline.
- Do not call `sleep()` in a browser test. The app server runs in the test process, so `sleep()` stops it; use `$page->wait()`.
- `Queue::fake()` also captures `dispatchSync()` of a `ShouldQueue` job, so the job never runs. Fake only the jobs to block (`Queue::fake([SomeJob::class])`).
- `Queue::fake()` also captures `dispatchSync()` of a `ShouldQueue` job, so the job never runs. Fake only the jobs to block (`Queue::fake([SomeJob::class])`). Exception: actions (`AsAction`) are queued as `JobDecorator`, so `Queue::fake([SomeAction::class])` does not stop them; use `Queue::fake()` and `SomeAction::assertPushed()`.
- `Notification::fake()` records nothing for a team that has no enabled channel. Turn one on (for example `emailNotificationSettings()->update(['use_instance_email_settings' => true, '<event>_email_notifications' => true])`) before `assertSentTo`.
- In tests, turning on `is_metrics_enabled` (and other Sentinel settings) with `save()`/`update()` runs the settings hook, which starts Sentinel over a real SSH connection on the sync queue. Use `saveQuietly()` or `Queue::fake()`.
- Call `visit()` directly in each `tests/v4/Browser` test body; Pest does not mark a test that only uses helper-wrapped `visit()` as a browser test, so it fails with `sendText() on null`.
- The root team (id 0) has `personal_team = true`, so `personal_team` does not identify a user's own team. To clean up test users, detach them from shared teams and then delete the user; never delete a team selected by a filter. A Team delete revokes the team's API tokens and deletes its private keys before any guard can stop it.
- SQLite returns `0`/`1` for boolean columns without a `boolean` cast, while Postgres returns real booleans. A strict `=== false` check on such a column never matches in tests, so tests can pass on behavior that Postgres blocks. Add the cast or compare loosely.
- SQLite does not enforce `varchar(255)` lengths, while Postgres rejects a longer value (`SQLSTATE[22001]`). Tests cannot show a too-long name, so assert the length or the exact value of generated strings.
- The Compose parsers (`parse()`, `applicationParser`, `serviceParser`) run on page mounts, refreshes, and deployments. Do not dispatch jobs or run server commands from them; let the caller that changes data start the work. Code that only needs the service names reads `docker_compose_raw` (`Application::composeServicesWithoutParse()`).
## Verify the complete user flow
- Do not use a passing unit test, a successful build, or a healthy process as proof for a reported UI failure.
+1 -1
View File
@@ -318,7 +318,7 @@ class StartDatabaseImport
private function setMinioClientAlias(S3Storage $storage, Server $server, string $helper): void
{
$remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($helper)
.' mc alias set s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null';
.' mc alias set'.$storage->minioClientPathOption().' s3temp '.escapeshellarg((string) $storage->endpoint).' >/dev/null';
$timeout = (int) config('constants.ssh.command_timeout');
SshRetryHandler::retry(
+27 -1
View File
@@ -13,6 +13,7 @@ use App\Models\StandaloneMysql;
use App\Models\StandalonePostgresql;
use App\Models\StandaloneRedis;
use App\Notifications\Container\ContainerRestarted;
use Illuminate\Support\Facades\Cache;
use Lorisleiva\Actions\Concerns\AsAction;
use Lorisleiva\Actions\Decorators\JobDecorator;
use Symfony\Component\Yaml\Yaml;
@@ -21,6 +22,27 @@ class StartDatabaseProxy
{
use AsAction;
/**
* Version of the generated proxy configuration. Raise it when the configuration changes, so the
* status checks recreate proxies that an older Coolify version started with the old configuration.
*/
public const CONFIG_VERSION = '2';
public const CONFIG_VERSION_LABEL = 'coolify.databaseProxyConfigVersion';
/**
* True when a running proxy has an older configuration and no recreation was claimed in the last
* ten minutes. The claim stops a busy queue from recreating the proxy again before the first run.
*/
public static function claimOutdatedRefresh(string $databaseUuid, mixed $labels): bool
{
if ((string) collect($labels)->get(self::CONFIG_VERSION_LABEL) === self::CONFIG_VERSION) {
return false;
}
return Cache::add("database-proxy-refresh:{$databaseUuid}", true, now()->addMinutes(10));
}
public function configureJob(JobDecorator $job): void
{
$job->onQueue(deployment_queue());
@@ -85,6 +107,9 @@ class StartDatabaseProxy
'image' => 'nginx:stable-alpine',
'container_name' => $proxyContainerName,
'restart' => RESTART_MODE,
'labels' => [
self::CONFIG_VERSION_LABEL => self::CONFIG_VERSION,
],
'ports' => [
"$database->public_port:$database->public_port",
],
@@ -120,7 +145,6 @@ class StartDatabaseProxy
];
$dockercompose_base64 = base64_encode(Yaml::dump($docker_compose, 4, 2));
$nginxconf_base64 = base64_encode($nginxconf);
instant_remote_process(["docker rm -f $proxyContainerName"], $server, false);
try {
instant_remote_process([
@@ -129,6 +153,8 @@ class StartDatabaseProxy
"echo '{$dockercompose_base64}' | base64 -d | tee $configuration_dir/docker-compose.yaml > /dev/null",
// Pass -f so Compose uses only the file that Coolify wrote.
"docker compose --project-directory {$configuration_dir} -f {$configuration_dir}/docker-compose.yaml pull",
// Remove the old proxy only after the pull, so a failed pull keeps it running.
dockerRemoveCommand($proxyContainerName),
"docker compose --project-directory {$configuration_dir} -f {$configuration_dir}/docker-compose.yaml up -d",
], $server);
} catch (\RuntimeException $e) {
@@ -155,10 +155,18 @@ class StartDevelopmentQemuVm
/**
* nvidia-persistenced keeps the GPU open, which blocks libvirt from moving it to vfio-pci.
* Other users of the GPU (for example a desktop session) must stop first: if libvirt unbinds the
* nvidia driver while they hold the GPU open, the GPU stays unusable until the host reboots.
*/
private function releaseHostGpu(): void
{
Process::run('systemctl stop nvidia-persistenced');
$users = Process::run('fuser -v /dev/nvidia[0-9]* 2>&1');
if ($users->successful() && trim($users->output()) !== '') {
throw new RuntimeException("These host processes still use the NVIDIA GPU, so it cannot move to the VM. Stop them (for example the display manager), or remove the GPU from the VM with --fresh and without --gpu:\n".trim($users->output()));
}
}
private function destroyVm(string $domain): void
+9 -1
View File
@@ -241,6 +241,8 @@ class GetContainersStatus implements ShouldBeUnique
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($service_db);
} elseif (! $this->server->isSwarm() && StartDatabaseProxy::claimOutdatedRefresh($proxyUuid, data_get($foundTcpProxy, 'Config.Labels'))) {
StartDatabaseProxy::run($service_db);
}
} else {
// Clean up orphaned proxy when is_public=false
@@ -292,6 +294,8 @@ class GetContainersStatus implements ShouldBeUnique
})->first();
if (! $foundTcpProxy) {
StartDatabaseProxy::run($database);
} elseif (! $this->server->isSwarm() && StartDatabaseProxy::claimOutdatedRefresh($uuid, data_get($foundTcpProxy, 'Config.Labels'))) {
StartDatabaseProxy::run($database);
}
} else {
// Clean up orphaned proxy when is_public=false
@@ -529,7 +533,9 @@ class GetContainersStatus implements ShouldBeUnique
->where('restart_limit_reached', false)
->update(['restart_limit_reached' => true]) === 1;
if ($restartLimitClaimed) {
// Stop again while a container still runs: an earlier stop can fail or come too late.
$stillRunning = $containerStatuses->contains(fn (string $status): bool => ! str($status)->contains('exited'));
if ($restartLimitClaimed || $stillRunning) {
$application->refresh();
StopApplication::dispatch(
application: $application,
@@ -538,6 +544,8 @@ class GetContainersStatus implements ShouldBeUnique
resetRestartCount: false,
removeContainers: false,
);
}
if ($restartLimitClaimed) {
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
}
+44 -38
View File
@@ -3,8 +3,10 @@
namespace App\Actions\Server;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Models\Service;
use App\Rules\ValidFluentBitConfig;
use Illuminate\Support\Facades\Cache;
use Lorisleiva\Actions\Concerns\AsAction;
class StartLogDrain
@@ -13,12 +15,31 @@ class StartLogDrain
public string $jobQueue = 'high';
/**
* Bump when the generated files change (image, config, compose, or README), so that the
* server checks start running log drains again with the new files.
*/
public const CONFIG_VERSION = '1';
public const CONFIG_VERSION_LABEL = 'coolify.logDrainConfigVersion';
/**
* Whether a running log drain container was made with older files. Claims the refresh for
* 10 minutes, so that the frequent server checks queue only one restart.
*/
public static function claimOutdatedRefresh(Server $server, mixed $labels): bool
{
if ((string) collect($labels)->get(self::CONFIG_VERSION_LABEL) === self::CONFIG_VERSION) {
return false;
}
return Cache::add("log-drain-refresh:{$server->uuid}", true, now()->addMinutes(10));
}
public function handle(Server $server)
{
if ($server->settings->is_logdrain_newrelic_enabled) {
$type = 'newrelic';
} elseif ($server->settings->is_logdrain_highlight_enabled) {
$type = 'highlight';
} elseif ($server->settings->is_logdrain_axiom_enabled) {
$type = 'axiom';
} elseif ($server->settings->is_logdrain_custom_enabled) {
@@ -34,9 +55,6 @@ class StartLogDrain
return 'Amazon CloudWatch Logs uses the built-in Docker awslogs driver.';
}
if ($type !== 'none') {
StopLogDrain::run($server);
}
$serverName = $this->fluentBitValue((string) $server->name);
try {
if ($type === 'none') {
@@ -49,8 +67,7 @@ class StartLogDrain
[SERVICE]
Flush 5
Daemon off
Tag container_logs
Log_Level debug
Log_Level warn
Parsers_File parsers.conf
[INPUT]
Name forward
@@ -76,27 +93,6 @@ class StartLogDrain
# https://log-api.newrelic.com/log/v1 - US
base_uri \${BASE_URI}
");
} elseif ($type === 'highlight') {
if (! $server->settings->is_logdrain_highlight_enabled) {
throw new \Exception('Highlight log drain is not enabled.');
}
$config = base64_encode('
[SERVICE]
Flush 5
Daemon off
Log_Level debug
Parsers_File parsers.conf
[INPUT]
Name forward
tag ${HIGHLIGHT_PROJECT_ID}
Buffer_Chunk_Size 1M
Buffer_Max_Size 6M
[OUTPUT]
Name forward
Match *
Host otel.highlight.io
Port 24224
');
} elseif ($type === 'axiom') {
if (! $server->settings->is_logdrain_axiom_enabled) {
throw new \Exception('Axiom log drain is not enabled.');
@@ -105,7 +101,7 @@ class StartLogDrain
[SERVICE]
Flush 5
Daemon off
Log_Level debug
Log_Level warn
Parsers_File parsers.conf
[INPUT]
Name forward
@@ -126,9 +122,9 @@ class StartLogDrain
[OUTPUT]
Name http
Match *
Host api.axiom.co
Host \${AXIOM_DOMAIN}
Port 443
URI /v1/datasets/\${AXIOM_DATASET_NAME}/ingest
URI /v1/ingest/\${AXIOM_DATASET_NAME}
# Authorization Bearer should be an API token
Header Authorization Bearer \${AXIOM_API_KEY}
compress gzip
@@ -165,7 +161,7 @@ class StartLogDrain
$compose = base64_encode('
services:
coolify-log-drain:
image: cr.fluentbit.io/fluent/fluent-bit:2.0
image: cr.fluentbit.io/fluent/fluent-bit:5.1
container_name: coolify-log-drain
command: -c /fluent-bit.conf
env_file:
@@ -177,16 +173,22 @@ services:
- 127.0.0.1:24224:24224
labels:
- coolify.managed=true
- '.self::CONFIG_VERSION_LABEL.'='.self::CONFIG_VERSION.'
restart: unless-stopped
');
$readme = base64_encode('# New Relic Log Drain
This log drain is based on [Fluent Bit](https://fluentbit.io/) and New Relic Log Forwarder.
$providerName = match ($type) {
'newrelic' => 'New Relic',
'axiom' => 'Axiom',
'custom' => 'Custom',
};
$readme = base64_encode("# {$providerName} Log Drain
This log drain is based on [Fluent Bit](https://fluentbit.io/).
Files:
- `fluent-bit.conf` - configuration file for Fluent Bit
- `docker-compose.yml` - docker-compose file to run Fluent Bit
- `.env` - environment variables for Fluent Bit
');
");
$license_key = $server->settings->logdrain_newrelic_license_key;
$base_uri = $server->settings->logdrain_newrelic_base_uri;
$base_path = config('constants.coolify.base_config_path');
@@ -198,10 +200,9 @@ Files:
$readme_path = $config_path.'/README.md';
if ($type === 'newrelic') {
$envContent = "LICENSE_KEY={$license_key}\nBASE_URI={$base_uri}\n";
} elseif ($type === 'highlight') {
$envContent = "HIGHLIGHT_PROJECT_ID={$server->settings->logdrain_highlight_project_id}\n";
} elseif ($type === 'axiom') {
$envContent = "AXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n";
$axiomDomain = trim((string) $server->settings->logdrain_axiom_domain) ?: ServerSetting::DEFAULT_AXIOM_DOMAIN;
$envContent = "AXIOM_DOMAIN={$axiomDomain}\nAXIOM_DATASET_NAME={$server->settings->logdrain_axiom_dataset_name}\nAXIOM_API_KEY={$server->settings->logdrain_axiom_api_key}\n";
} elseif ($type === 'custom') {
$envContent = '';
} else {
@@ -209,6 +210,8 @@ Files:
}
$envEncoded = base64_encode($envContent);
// The running drain is removed only after the checks above and the image pull, so that an
// invalid custom config or a slow download does not stop log collection.
$command = [
"echo 'Saving configuration'",
"mkdir -p $config_path",
@@ -217,7 +220,10 @@ Files:
"echo '{$compose}' | base64 -d | tee $compose_path > /dev/null",
"echo '{$readme}' | base64 -d | tee $readme_path > /dev/null",
"echo '{$envEncoded}' | base64 -d | tee $config_path/.env > /dev/null",
"echo 'Pulling Fluent Bit'",
"docker compose -f $compose_path pull --quiet --ignore-pull-failures",
"echo 'Starting Fluent Bit'",
'docker rm -f coolify-log-drain >/dev/null 2>&1 || true',
"docker compose -f $compose_path up -d",
];
$command = array_merge($command, $this->logDrainNetworkConnectCommands($server));
+13
View File
@@ -37,6 +37,19 @@ class ValidateServer
$server->update([
'validation_logs' => null,
]);
if ($server->hetzner_server_id) {
$status = $server->refreshHetznerState();
if (in_array($status, ['initializing', 'starting', 'stopping', 'off', 'deleting', 'rebuilding', 'migrating'], true)) {
$this->error = $status === 'off'
? 'Hetzner server is off. Power it on before validating.'
: "Hetzner server is {$status}. Wait until it is running, then validate.";
$server->update([
'validation_logs' => $this->error,
]);
throw new \Exception($this->error);
}
}
if ($server->vultr_instance_id) {
$status = $server->refreshVultrState();
if (in_array($status, ['stopped', 'suspended', 'deleted'], true)) {
@@ -20,6 +20,7 @@ class DeployServiceApplication
$composeServiceName = $serviceApplication->name;
$service->parse();
StartService::queueFileStorageSync($service);
$service->saveComposeConfigs();
$service->isConfigurationChanged(save: true);
+14
View File
@@ -3,6 +3,7 @@
namespace App\Actions\Service;
use App\Actions\Shared\EnsureContentFilesOnServer;
use App\Jobs\ServerFilesFromServerJob;
use App\Models\LocalFileVolume;
use App\Models\Service;
use App\Models\ServiceApplication;
@@ -24,6 +25,7 @@ class StartService
public function handle(Service $service, bool $pullLatestImages = false, bool $stopBeforeStart = false)
{
$service->parse();
self::queueFileStorageSync($service);
// Fetch remote secrets before stopping: if the secret manager fails, the service keeps running.
// saveComposeConfigs() below reuses the fetched secrets.
$service->ensureRemoteSecretsResolvable($service->environment_variables()->get());
@@ -103,6 +105,18 @@ class StartService
return "{$connectedNetworks} | grep -qxF {$network} || docker network connect --alias {$containerName} {$network} {$containerName} || echo {$warning}";
}
/**
* Queues one sync of the file storages with the server for each resource of the service that has
* file storages. The sync marks each storage as a file or a directory, as it is on the server.
*/
public static function queueFileStorageSync(Service $service): void
{
$service->applications()->get()
->concat($service->databases()->get())
->filter(fn (ServiceApplication|ServiceDatabase $resource): bool => $resource->fileStorages()->exists())
->each(fn (ServiceApplication|ServiceDatabase $resource) => ServerFilesFromServerJob::dispatch($resource));
}
/**
* Shows the volume warnings of the last parse (for example an external volume that the
* service does not use yet) in the start log.
-1
View File
@@ -137,7 +137,6 @@ class DeleteUserTeams
// Update the new owner's role to owner
$team->members()->updateExistingPivot($newOwner->id, ['role' => 'owner']);
RevokeUserTeamTokens::forUserTeam($newOwner, $team->id);
// Remove the current user from the team
$team->members()->detach($this->user->id);
+2 -2
View File
@@ -21,7 +21,7 @@ class OidcDiscoveryService
$url = $issuerUrl.'/.well-known/openid-configuration';
try {
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($url);
$response = Http::timeout(15)->acceptJson()->get($url);
} catch (Throwable $e) {
throw new OidcDiscoveryException("Failed to fetch discovery document: {$e->getMessage()}", previous: $e);
}
@@ -68,7 +68,7 @@ class OidcDiscoveryService
return Cache::remember($cacheKey, 21600, function () use ($jwksUri): array {
try {
$response = Http::timeout(5)->connectTimeout(3)->acceptJson()->get($jwksUri);
$response = Http::timeout(15)->acceptJson()->get($jwksUri);
} catch (Throwable $e) {
throw new OidcJwksException("Failed to fetch JWKS: {$e->getMessage()}", previous: $e);
}
@@ -0,0 +1,73 @@
<?php
namespace App\Exceptions;
use Exception;
use Illuminate\Http\JsonResponse;
/**
* An error response from a cloud provider API (Hetzner, DigitalOcean, Vultr).
*
* @see https://docs.hetzner.cloud/reference/cloud#errors
*/
class CloudProviderApiException extends Exception
{
public readonly ?string $errorCode;
/** @var array<int, string> */
public readonly array $invalidFields;
/**
* The error code and field names come from the provider, so only values that look like identifiers are kept.
*
* @param array<int, mixed> $invalidFields
*/
public function __construct(
public readonly string $provider,
string $providerMessage,
public readonly int $status,
mixed $errorCode = null,
array $invalidFields = [],
) {
parent::__construct("{$provider} API error: {$providerMessage}", $status);
$errorCode = is_string($errorCode) ? strtolower($errorCode) : null;
$this->errorCode = $errorCode !== null && preg_match('/^[a-z_]{1,64}$/', $errorCode) ? $errorCode : null;
$this->invalidFields = array_values(array_filter(
$invalidFields,
fn ($name) => is_string($name) && preg_match('/^[a-z0-9_.\[\]]{1,64}$/i', $name)
));
}
/**
* A message that is safe to return to API clients. It is built only from the
* HTTP status, the error code, and field names, never from the provider's free-text message.
*/
public function safeMessage(): string
{
return match (true) {
$this->errorCode === 'resource_limit_exceeded' => "Your {$this->provider} account has reached a resource limit, for example the server limit. Remove unused resources or ask {$this->provider} for a higher limit.",
$this->errorCode === 'resource_unavailable' => 'The selected server type, image, or location is not available now. Select a different location or server type.',
$this->errorCode === 'uniqueness_error' => "A resource with the same name already exists in your {$this->provider} account, for example a server or an SSH key. Use a different name.",
$this->errorCode === 'maintenance', $this->status === 423, $this->status >= 500 => "{$this->provider} cannot process the request now. Try again later.",
$this->status === 401 => "The {$this->provider} API token is invalid or expired. Update the cloud provider token in Coolify.",
$this->status === 403 => "The {$this->provider} API token does not have permission for this request. Use a token with read and write access.",
$this->status === 404 => "{$this->provider} could not find a requested resource, for example the location, server type, image, or SSH key.",
$this->status === 409 => "{$this->provider} rejected the request because of a conflict with an existing resource.",
$this->status === 422 => "{$this->provider} rejected the request as invalid. Check the selected values and your account limits.".($this->invalidFields !== [] ? ' Invalid fields: '.implode(', ', $this->invalidFields).'.' : ''),
default => "{$this->provider} returned HTTP {$this->status}".($this->errorCode !== null ? " (error code: {$this->errorCode})" : '').'.',
};
}
/**
* The API response for a failed provider request: 502, because the upstream API rejected it.
*/
public function toJsonResponse(string $failureMessage): JsonResponse
{
return response()->json([
'message' => $failureMessage.' '.$this->safeMessage(),
'provider_status' => $this->status,
'provider_error_code' => $this->errorCode,
], 502);
}
}
@@ -381,7 +381,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'git_repository', 'git_branch', 'build_pack'],
required: ['project_uuid', 'server_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -593,7 +593,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
required: ['project_uuid', 'server_uuid', 'github_app_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -803,7 +803,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
required: ['project_uuid', 'server_uuid', 'private_key_uuid', 'git_repository', 'git_branch', 'build_pack'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -1013,7 +1013,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'dockerfile'],
required: ['project_uuid', 'server_uuid', 'dockerfile'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -1192,7 +1192,7 @@ class ApplicationsController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['project_uuid', 'server_uuid', 'environment_name', 'environment_uuid', 'docker_registry_image_name'],
required: ['project_uuid', 'server_uuid', 'docker_registry_image_name'],
properties: [
'project_uuid' => ['type' => 'string', 'description' => 'The project UUID.'],
'server_uuid' => ['type' => 'string', 'description' => 'The server UUID.'],
@@ -2417,7 +2417,7 @@ class ApplicationsController extends Controller
]))->setStatusCode(201);
} elseif ($type === 'dockerimage') {
$validationRules = [
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat],
'docker_registry_image_name' => ['required', 'string', 'max:255', new DockerImageFormat(allowVariableTag: true)],
'docker_registry_image_tag' => ValidationPatterns::dockerImageTagRules(),
'ports_exposes' => 'string|regex:/^(\d+)(,\d+)*$/|nullable',
];
@@ -2662,7 +2662,7 @@ class ApplicationsController extends Controller
new OA\Parameter(
name: 'service_name',
in: 'query',
description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.',
description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.',
required: false,
schema: new OA\Schema(type: 'string'),
),
@@ -2741,7 +2741,7 @@ class ApplicationsController extends Controller
new OA\Parameter(
name: 'service_name',
in: 'query',
description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no running container matches.',
description: 'Return logs only from the container of the Docker Compose service with this name. Returns 404 when no container matches. Stopped containers are included.',
required: false,
schema: new OA\Schema(type: 'string'),
),
@@ -2808,20 +2808,13 @@ class ApplicationsController extends Controller
if (! $matchingContainer) {
return response()->json([
'message' => "No running container found for service_name '{$serviceName}'.",
'message' => "No container found for service_name '{$serviceName}'.",
], 404);
}
$container = $matchingContainer;
} else {
$container = $containers->first();
}
$status = getContainerStatus($application->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Application is not running.',
], 400);
$container = $containers->firstWhere('State', 'running') ?? $containers->first();
}
$lines = normalizeLogLines($request->query('lines'));
@@ -17,7 +17,7 @@ trait RequiresDeployAbility
*/
private function deployAbilityForbiddenResponse(Request $request, string $reason): ?JsonResponse
{
if ($request->user()->tokenCan('deploy') || $request->user()->tokenCan('root')) {
if ($this->hasDeployAbility($request)) {
return null;
}
@@ -25,4 +25,9 @@ trait RequiresDeployAbility
'message' => "Missing required permissions: deploy. {$reason}",
], 403);
}
private function hasDeployAbility(Request $request): bool
{
return $request->user()->tokenCan('deploy') || $request->user()->tokenCan('root');
}
}
@@ -352,8 +352,8 @@ class DatabasesController extends Controller
response: 200,
description: 'Get all databases',
content: new OA\JsonContent(
type: 'string',
example: 'Content is very complex. Will be implemented later.',
type: 'array',
items: new OA\Items(ref: '#/components/schemas/Database'),
),
),
new OA\Response(
@@ -511,11 +511,8 @@ class DatabasesController extends Controller
responses: [
new OA\Response(
response: 200,
description: 'Get all databases',
content: new OA\JsonContent(
type: 'string',
example: 'Content is very complex. Will be implemented later.',
),
description: 'Get database by UUID',
content: new OA\JsonContent(ref: '#/components/schemas/Database'),
),
new OA\Response(
response: 401,
@@ -1550,7 +1547,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -1623,7 +1620,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -1692,7 +1689,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -1760,7 +1757,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -1829,7 +1826,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -1898,7 +1895,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -1970,7 +1967,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -2042,7 +2039,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -2111,7 +2108,7 @@ class DatabasesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'server_uuid' => ['type' => 'string', 'description' => 'UUID of the server'],
'project_uuid' => ['type' => 'string', 'description' => 'UUID of the project'],
@@ -2900,13 +2897,6 @@ class DatabasesController extends Controller
$container = $containers->first();
$status = getContainerStatus($database->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Database is not running.',
], 400);
}
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = sanitizeLogsForExport(
@@ -4150,6 +4140,7 @@ class DatabasesController extends Controller
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
$item = Arr::only($item, ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment']);
if (array_key_exists('key', $item)) {
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
}
@@ -5292,7 +5283,7 @@ class DatabasesController extends Controller
$uuid = new_public_id();
$name = $request->filled('name')
? $request->string('name')->toString()
: $database->name.'-clone-'.$uuid;
: clone_resource_name($database->name, $uuid);
$cloneVolumeData = $request->boolean('clone_volumes', false);
$newDatabase = $database->replicate([
@@ -2,11 +2,12 @@
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\CloudProviderApiException;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
use App\Http\Controllers\Controller;
use App\Jobs\ValidateNewCloudServerJob;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
@@ -210,6 +211,7 @@ class DigitalOceanController extends Controller
new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'DigitalOcean rate limit exceeded.'),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function createServer(Request $request): JsonResponse
@@ -285,7 +287,13 @@ class DigitalOceanController extends Controller
$request->offsetSet('enable_ipv6', $request->boolean('enable_ipv6', true));
$request->offsetSet('monitoring', $request->boolean('monitoring', true));
$request->offsetSet('digitalocean_ssh_key_ids', $request->digitalocean_ssh_key_ids ?? []);
$request->offsetSet('instant_validate', $request->boolean('instant_validate', false));
$request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request));
if ($request->instant_validate) {
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.');
if ($forbidden) {
return $forbidden;
}
}
$token = $this->digitalOceanToken($request, $teamId);
if ($token instanceof JsonResponse) {
@@ -363,7 +371,7 @@ class DigitalOceanController extends Controller
}
if ($request->instant_validate) {
ValidateServer::dispatch($server);
ValidateNewCloudServerJob::dispatch($server);
}
auditLog('api.digitalocean_droplet.created', [
@@ -395,12 +403,13 @@ class DigitalOceanController extends Controller
}
return $response;
} catch (\Throwable $e) {
} catch (CloudProviderApiException $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
logger()->error('Failed to create DigitalOcean server', [
'error' => $e->getMessage(),
]);
return $e->toJsonResponse('Failed to create DigitalOcean server.');
} catch (\Throwable $e) {
$this->deleteUntrackedDroplet($digitalOceanService, $dropletId, $server);
report($e);
return response()->json(['message' => 'Failed to create DigitalOcean server.'], 500);
}
+63 -7
View File
@@ -2,11 +2,12 @@
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\CloudProviderApiException;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
use App\Http\Controllers\Controller;
use App\Jobs\ValidateNewCloudServerJob;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
@@ -90,6 +91,7 @@ class HetznerController extends Controller
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function locations(Request $request)
@@ -127,14 +129,18 @@ class HetznerController extends Controller
$locations = $hetznerService->getLocations();
return response()->json($locations);
} catch (CloudProviderApiException $e) {
return $e->toJsonResponse('Failed to fetch Hetzner locations.');
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Failed to fetch Hetzner locations.'], 500);
}
}
#[OA\Get(
summary: 'Get Hetzner Server Types',
description: 'Get all available Hetzner server types (instance sizes).',
description: 'Get the Hetzner server types (instance sizes) that can be ordered now. Each type lists only the locations where it is in stock and not deprecated.',
path: '/hetzner/server-types',
operationId: 'get-hetzner-server-types',
security: [
@@ -142,6 +148,13 @@ class HetznerController extends Controller
],
tags: ['Hetzner'],
parameters: [
new OA\Parameter(
name: 'location',
in: 'query',
required: false,
description: 'Return only the server types that can be ordered in this location, for example nbg1.',
schema: new OA\Schema(type: 'string')
),
new OA\Parameter(
name: 'cloud_provider_token_uuid',
in: 'query',
@@ -212,6 +225,7 @@ class HetznerController extends Controller
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function serverTypes(Request $request)
@@ -224,6 +238,7 @@ class HetznerController extends Controller
$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' => 'nullable|string',
]);
if ($validator->fails()) {
@@ -246,10 +261,14 @@ class HetznerController extends Controller
try {
$hetznerService = new HetznerService($token->token);
$serverTypes = $hetznerService->getServerTypes();
$serverTypes = $hetznerService->getServerTypes($request->query('location'));
return response()->json($serverTypes);
} catch (CloudProviderApiException $e) {
return $e->toJsonResponse('Failed to fetch Hetzner server types.');
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Failed to fetch Hetzner server types.'], 500);
}
}
@@ -312,6 +331,7 @@ class HetznerController extends Controller
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function images(Request $request)
@@ -362,7 +382,11 @@ class HetznerController extends Controller
});
return response()->json(array_values($filtered));
} catch (CloudProviderApiException $e) {
return $e->toJsonResponse('Failed to fetch Hetzner images.');
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Failed to fetch Hetzner images.'], 500);
}
}
@@ -422,6 +446,7 @@ class HetznerController extends Controller
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function sshKeys(Request $request)
@@ -459,7 +484,11 @@ class HetznerController extends Controller
$sshKeys = $hetznerService->getSshKeys();
return response()->json($sshKeys);
} catch (CloudProviderApiException $e) {
return $e->toJsonResponse('Failed to fetch Hetzner SSH keys.');
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Failed to fetch Hetzner SSH keys.'], 500);
}
}
@@ -517,6 +546,7 @@ class HetznerController extends Controller
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function firewalls(Request $request)
@@ -553,7 +583,11 @@ class HetznerController extends Controller
$hetznerService = new HetznerService($token->token);
return response()->json($hetznerService->getFirewalls());
} catch (CloudProviderApiException $e) {
return $e->toJsonResponse('Failed to fetch Hetzner firewalls.');
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Failed to fetch Hetzner firewalls.'], 500);
}
}
@@ -612,6 +646,7 @@ class HetznerController extends Controller
response: 404,
ref: '#/components/responses/404',
),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function networks(Request $request)
@@ -648,7 +683,11 @@ class HetznerController extends Controller
$hetznerService = new HetznerService($token->token);
return response()->json($hetznerService->getNetworks());
} catch (CloudProviderApiException $e) {
return $e->toJsonResponse('Failed to fetch Hetzner networks.');
} catch (\Throwable $e) {
report($e);
return response()->json(['message' => 'Failed to fetch Hetzner networks.'], 500);
}
}
@@ -685,7 +724,7 @@ class HetznerController extends Controller
'hetzner_firewall_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner firewall IDs to apply during server creation'],
'hetzner_network_ids' => ['type' => 'array', 'items' => ['type' => 'integer'], 'description' => 'Existing Hetzner network IDs to attach during server creation'],
'cloud_init_script' => ['type' => 'string', 'description' => 'Cloud-init YAML script (optional). Needs a token with the deploy permission.'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate server immediately after creation'],
'instant_validate' => ['type' => 'boolean', 'example' => false, 'description' => 'Validate the server and install Docker when it is running. Defaults to true for tokens with the deploy permission, and needs that permission.'],
],
),
),
@@ -728,6 +767,7 @@ class HetznerController extends Controller
response: 429,
ref: '#/components/responses/429',
),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function createServer(Request $request)
@@ -832,8 +872,12 @@ class HetznerController extends Controller
if (is_null($request->hetzner_network_ids)) {
$request->offsetSet('hetzner_network_ids', []);
}
if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false);
$request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request));
if ($request->instant_validate) {
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.');
if ($forbidden) {
return $forbidden;
}
}
if (! $request->boolean('enable_ipv4') && ! $request->boolean('enable_ipv6')) {
@@ -960,6 +1004,7 @@ class HetznerController extends Controller
'private_key_id' => $privateKey->id,
'cloud_provider_token_id' => $token->id,
'hetzner_server_id' => $hetznerServer['id'],
'hetzner_server_status' => $hetznerServer['status'] ?? null,
]);
$server->proxy->set('status', 'exited');
@@ -976,7 +1021,7 @@ class HetznerController extends Controller
// Validate server if requested
if ($request->instant_validate) {
ValidateServer::dispatch($server);
ValidateNewCloudServerJob::dispatch($server);
}
auditLog('api.hetzner_server.created', [
@@ -1012,7 +1057,18 @@ class HetznerController extends Controller
}
return $response;
} catch (CloudProviderApiException $e) {
return $e->toJsonResponse('Failed to create Hetzner server.');
} catch (\Throwable $e) {
report($e);
if (isset($hetznerService, $hetznerServer['id'])) {
try {
$hetznerService->deleteServer((int) $hetznerServer['id']);
} catch (\Throwable $cleanupError) {
report($cleanupError);
}
}
return response()->json(['message' => 'Failed to create Hetzner server.'], 500);
}
}
@@ -2,11 +2,11 @@
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
use App\Http\Controllers\Controller;
use App\Jobs\ValidateNewCloudServerJob;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
@@ -193,6 +193,14 @@ class HostingerController extends Controller
}
}
$request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request));
if ($request->instant_validate) {
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.');
if ($forbidden) {
return $forbidden;
}
}
$team = Team::find($teamId);
if (Team::serverLimitReached($team)) {
return response()->json(['message' => 'Server limit reached for your subscription.'], 400);
@@ -267,8 +275,8 @@ class HostingerController extends Controller
$ipAddress = $server->fresh()->ip;
if ($request->boolean('instant_validate') && ! $server->hasPlaceholderIp()) {
ValidateServer::dispatch($server);
if ($request->instant_validate) {
ValidateNewCloudServerJob::dispatch($server);
}
auditLog('api.hostinger_virtual_machine.created', [
+108
View File
@@ -35,6 +35,103 @@ use OpenApi\Attributes as OA;
new OA\Property(property: 'finished_at', type: 'string', format: 'date-time', nullable: true),
],
),
new OA\Schema(
schema: 'Database',
description: 'Standalone database. The fields that start with an engine name (for example postgres_ or redis_) are only returned for that engine. Passwords, connection URLs and init_scripts are only returned for tokens with the read:sensitive or root ability.',
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'description', type: 'string', nullable: true),
new OA\Property(property: 'database_type', type: 'string', enum: ['standalone-postgresql', 'standalone-redis', 'standalone-mongodb', 'standalone-mysql', 'standalone-mariadb', 'standalone-keydb', 'standalone-dragonfly', 'standalone-clickhouse', 'standalone-sqlite']),
new OA\Property(property: 'image', type: 'string'),
new OA\Property(property: 'status', type: 'string', description: 'Container status and health, for example running:healthy or exited:unhealthy.'),
new OA\Property(property: 'server_status', type: 'boolean', description: 'True when the server of the database is reachable and usable.'),
new OA\Property(property: 'environment_id', type: 'integer'),
new OA\Property(property: 'environment', ref: '#/components/schemas/Environment', description: 'Only returned by GET /databases/{uuid}. Includes the project.'),
new OA\Property(property: 'destination_id', type: 'integer'),
new OA\Property(property: 'destination_type', type: 'string'),
new OA\Property(
property: 'destination',
description: 'Docker network destination of the database, with its server.',
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'network', type: 'string'),
new OA\Property(property: 'server_id', type: 'integer'),
new OA\Property(property: 'server', ref: '#/components/schemas/Server'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
],
),
new OA\Property(property: 'is_public', type: 'boolean', description: 'Not returned for SQLite.'),
new OA\Property(property: 'public_port', type: 'integer', nullable: true, description: 'Not returned for SQLite.'),
new OA\Property(property: 'public_port_timeout', type: 'integer', nullable: true, description: 'Proxy timeout of the public port in seconds. Not returned for SQLite.'),
new OA\Property(property: 'ports_mappings', type: 'string', nullable: true, description: 'Comma-separated host:container port mappings. Not returned for SQLite.'),
new OA\Property(property: 'enable_ssl', type: 'boolean', description: 'Not returned for ClickHouse and SQLite.'),
new OA\Property(property: 'ssl_mode', type: 'string', nullable: true, description: 'PostgreSQL, MongoDB and MySQL only.'),
new OA\Property(property: 'internal_db_url', type: 'string', nullable: true, description: 'Connection URL inside the Docker network. Contains the password. Not returned for SQLite.'),
new OA\Property(property: 'external_db_url', type: 'string', nullable: true, description: 'Public connection URL, when the database is public. Contains the password. Not returned for SQLite.'),
new OA\Property(property: 'init_scripts', type: 'array', nullable: true, items: new OA\Items(type: 'object', properties: [new OA\Property(property: 'index', type: 'integer'), new OA\Property(property: 'filename', type: 'string'), new OA\Property(property: 'content', type: 'string')]), description: 'PostgreSQL only.'),
new OA\Property(property: 'custom_docker_run_options', type: 'string', nullable: true),
new OA\Property(property: 'is_log_drain_enabled', type: 'boolean'),
new OA\Property(property: 'is_include_timestamps', type: 'boolean', description: 'Not returned for MariaDB.'),
new OA\Property(property: 'include_coolify_default_variables', type: 'boolean'),
new OA\Property(property: 'redact_all_env_values_in_logs', type: 'boolean'),
new OA\Property(property: 'health_check_enabled', type: 'boolean'),
new OA\Property(property: 'health_check_interval', type: 'integer'),
new OA\Property(property: 'health_check_timeout', type: 'integer'),
new OA\Property(property: 'health_check_retries', type: 'integer'),
new OA\Property(property: 'health_check_start_period', type: 'integer'),
new OA\Property(property: 'limits_memory', type: 'string'),
new OA\Property(property: 'limits_memory_swap', type: 'string'),
new OA\Property(property: 'limits_memory_swappiness', type: 'integer'),
new OA\Property(property: 'limits_memory_reservation', type: 'string'),
new OA\Property(property: 'limits_cpus', type: 'string'),
new OA\Property(property: 'limits_cpuset', type: 'string', nullable: true),
new OA\Property(property: 'limits_cpu_shares', type: 'integer'),
new OA\Property(property: 'config_hash', type: 'string', nullable: true, description: 'Internal hash that Coolify uses to find configuration changes that need a restart. It changes only when the database starts, so do not use it to compare configurations.'),
new OA\Property(property: 'restart_count', type: 'integer'),
new OA\Property(property: 'last_restart_at', type: 'string', format: 'date-time', nullable: true),
new OA\Property(property: 'last_restart_type', type: 'string', nullable: true),
new OA\Property(property: 'last_online_at', type: 'string', nullable: true),
new OA\Property(property: 'started_at', type: 'string', format: 'date-time', nullable: true),
new OA\Property(property: 'backup_configs', type: 'array', items: new OA\Items(type: 'object'), description: 'Only returned by GET /databases. The scheduled backups of the database, as returned by GET /databases/{uuid}/backups.'),
new OA\Property(property: 'postgres_user', type: 'string'),
new OA\Property(property: 'postgres_password', type: 'string', format: 'password'),
new OA\Property(property: 'postgres_db', type: 'string'),
new OA\Property(property: 'postgres_initdb_args', type: 'string', nullable: true),
new OA\Property(property: 'postgres_host_auth_method', type: 'string', nullable: true),
new OA\Property(property: 'postgres_conf', type: 'string', nullable: true),
new OA\Property(property: 'redis_password', type: 'string', format: 'password'),
new OA\Property(property: 'redis_conf', type: 'string', nullable: true),
new OA\Property(property: 'mongo_initdb_root_username', type: 'string'),
new OA\Property(property: 'mongo_initdb_root_password', type: 'string', format: 'password'),
new OA\Property(property: 'mongo_initdb_database', type: 'string'),
new OA\Property(property: 'mongo_conf', type: 'string', nullable: true),
new OA\Property(property: 'mysql_user', type: 'string'),
new OA\Property(property: 'mysql_password', type: 'string', format: 'password'),
new OA\Property(property: 'mysql_root_password', type: 'string', format: 'password'),
new OA\Property(property: 'mysql_database', type: 'string'),
new OA\Property(property: 'mysql_conf', type: 'string', nullable: true),
new OA\Property(property: 'mariadb_user', type: 'string'),
new OA\Property(property: 'mariadb_password', type: 'string', format: 'password'),
new OA\Property(property: 'mariadb_root_password', type: 'string', format: 'password'),
new OA\Property(property: 'mariadb_database', type: 'string'),
new OA\Property(property: 'mariadb_conf', type: 'string', nullable: true),
new OA\Property(property: 'keydb_password', type: 'string', format: 'password'),
new OA\Property(property: 'keydb_conf', type: 'string', nullable: true),
new OA\Property(property: 'dragonfly_password', type: 'string', format: 'password'),
new OA\Property(property: 'clickhouse_admin_user', type: 'string'),
new OA\Property(property: 'clickhouse_admin_password', type: 'string', format: 'password'),
new OA\Property(property: 'clickhouse_db', type: 'string'),
new OA\Property(property: 'sqlite_databases', type: 'string', description: 'Comma-separated SQLite database file names.'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'deleted_at', type: 'string', format: 'date-time', nullable: true),
],
),
],
responses: [
new OA\Response(
@@ -101,6 +198,17 @@ use OpenApi\Attributes as OA;
new OA\Property(property: 'message', type: 'string', example: 'Rate limit exceeded. Please try again later.'),
]
)),
new OA\Response(
response: 502,
description: 'The cloud provider API rejected the request.',
content: new OA\JsonContent(
type: 'object',
properties: [
new OA\Property(property: 'message', type: 'string', example: 'Failed to create Hetzner server. The Hetzner API token does not have permission for this request. Use a token with read and write access.'),
new OA\Property(property: 'provider_status', type: 'integer', description: 'The HTTP status from the cloud provider API.', example: 403),
new OA\Property(property: 'provider_error_code', type: 'string', nullable: true, description: 'The error code from the cloud provider API, if it has one.', example: 'forbidden'),
]
)),
],
)]
class OpenApi
@@ -21,10 +21,23 @@ class ResourcesController extends Controller
responses: [
new OA\Response(
response: 200,
description: 'Get all resources',
description: 'Get all applications, services and standalone databases. The other fields of each resource are the fields of the Application, Service or Database schema.',
content: new OA\JsonContent(
type: 'string',
example: 'Content is very complex. Will be implemented later.',
type: 'array',
items: new OA\Items(
type: 'object',
properties: [
new OA\Property(property: 'uuid', type: 'string'),
new OA\Property(property: 'name', type: 'string'),
new OA\Property(property: 'description', type: 'string', nullable: true),
new OA\Property(property: 'type', type: 'string', description: 'application, service, or standalone-<engine> for databases (for example standalone-postgresql).'),
new OA\Property(property: 'status', type: 'string', description: 'For example running:healthy or exited:unhealthy.'),
new OA\Property(property: 'environment_id', type: 'integer'),
new OA\Property(property: 'created_at', type: 'string', format: 'date-time'),
new OA\Property(property: 'updated_at', type: 'string', format: 'date-time'),
],
additionalProperties: true,
),
),
),
new OA\Response(
@@ -97,6 +97,7 @@ class S3StoragesController extends Controller
'endpoint' => ['type' => 'string'],
'bucket' => ['type' => 'string'],
'region' => ['type' => 'string'],
'use_path_style_endpoint' => ['type' => 'boolean'],
'is_usable' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
@@ -160,6 +161,7 @@ class S3StoragesController extends Controller
'endpoint' => ['type' => 'string'],
'bucket' => ['type' => 'string'],
'region' => ['type' => 'string'],
'use_path_style_endpoint' => ['type' => 'boolean'],
'is_usable' => ['type' => 'boolean'],
'team_id' => ['type' => 'integer'],
'created_at' => ['type' => 'string'],
@@ -220,6 +222,7 @@ class S3StoragesController extends Controller
'endpoint' => ['type' => 'string', 'example' => 'https://s3.us-east-1.amazonaws.com', 'description' => 'S3-compatible endpoint URL.'],
'bucket' => ['type' => 'string', 'example' => 'my-bucket', 'description' => 'S3 bucket name.'],
'region' => ['type' => 'string', 'example' => 'us-east-1', 'description' => 'S3 region.'],
'use_path_style_endpoint' => ['type' => 'boolean', 'description' => 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint). Default: false for Alibaba Cloud OSS, Tencent Cloud COS, and Volcengine TOS endpoints, true for all other endpoints.'],
'key' => ['type' => 'string', 'description' => 'Access key.'],
'secret' => ['type' => 'string', 'description' => 'Secret key.'],
'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'],
@@ -258,7 +261,7 @@ class S3StoragesController extends Controller
)]
public function store(Request $request)
{
$allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'is_usable'];
$allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'is_usable'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -281,6 +284,7 @@ class S3StoragesController extends Controller
'region' => 'required|string|max:255',
'key' => 'required|string|max:255',
'secret' => 'required|string|max:255',
'use_path_style_endpoint' => 'sometimes|boolean',
'is_usable' => 'sometimes|boolean',
]);
if ($validationError instanceof JsonResponse) {
@@ -296,6 +300,7 @@ class S3StoragesController extends Controller
'region' => $body['region'],
'key' => $body['key'],
'secret' => $body['secret'],
'use_path_style_endpoint' => $body['use_path_style_endpoint'] ?? S3Storage::detectPathStyleEndpoint($body['endpoint']),
'is_usable' => $body['is_usable'] ?? false,
]);
@@ -335,6 +340,7 @@ class S3StoragesController extends Controller
'endpoint' => ['type' => 'string', 'description' => 'S3-compatible endpoint URL.'],
'bucket' => ['type' => 'string', 'description' => 'S3 bucket name.'],
'region' => ['type' => 'string', 'description' => 'S3 region.'],
'use_path_style_endpoint' => ['type' => 'boolean', 'description' => 'Use path-style URLs (endpoint/bucket). Set to false for providers that only accept virtual-hosted-style URLs (bucket.endpoint), such as Alibaba Cloud OSS.'],
'key' => ['type' => 'string', 'description' => 'Access key.'],
'secret' => ['type' => 'string', 'description' => 'Secret key.'],
'is_usable' => ['type' => 'boolean', 'description' => 'Whether the storage is marked usable.'],
@@ -373,7 +379,7 @@ class S3StoragesController extends Controller
)]
public function update(Request $request)
{
$allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'is_usable'];
$allowedFields = ['name', 'description', 'endpoint', 'bucket', 'region', 'key', 'secret', 'use_path_style_endpoint', 'is_usable'];
$teamId = getTeamIdFromToken();
if (is_null($teamId)) {
@@ -395,6 +401,7 @@ class S3StoragesController extends Controller
'region' => 'sometimes|string|max:255',
'key' => 'sometimes|string|max:255',
'secret' => 'sometimes|string|max:255',
'use_path_style_endpoint' => 'sometimes|boolean',
'is_usable' => 'sometimes|boolean',
]);
if ($validationError instanceof JsonResponse) {
@@ -7,6 +7,7 @@ use App\Actions\Server\StopLogDrain;
use App\Http\Controllers\Controller;
use App\Models\Server;
use App\Rules\ValidFluentBitConfig;
use App\Rules\ValidHostname;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use OpenApi\Attributes as OA;
@@ -20,6 +21,7 @@ class ServerLogDrainsController extends Controller
'is_logdrain_axiom_enabled',
'logdrain_axiom_dataset_name',
'logdrain_axiom_api_key',
'logdrain_axiom_domain',
'is_logdrain_custom_enabled',
'logdrain_custom_config',
'logdrain_custom_config_parser',
@@ -47,6 +49,7 @@ class ServerLogDrainsController extends Controller
'logdrain_newrelic_base_uri' => $settings->logdrain_newrelic_base_uri,
'is_logdrain_axiom_enabled' => (bool) $settings->is_logdrain_axiom_enabled,
'logdrain_axiom_dataset_name' => $settings->logdrain_axiom_dataset_name,
'logdrain_axiom_domain' => $settings->logdrain_axiom_domain,
'is_logdrain_custom_enabled' => (bool) $settings->is_logdrain_custom_enabled,
'is_logdrain_cloudwatch_enabled' => (bool) $settings->is_logdrain_cloudwatch_enabled,
'logdrain_cloudwatch_region' => $settings->logdrain_cloudwatch_region,
@@ -85,6 +88,7 @@ class ServerLogDrainsController extends Controller
new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string', nullable: true),
new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'),
new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string', nullable: true),
new OA\Property(property: 'logdrain_axiom_domain', type: 'string', nullable: true),
new OA\Property(property: 'logdrain_axiom_api_key', type: 'string', description: 'Only present with read:sensitive.'),
new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'),
new OA\Property(property: 'logdrain_custom_config', type: 'string', description: 'Only present with read:sensitive.'),
@@ -138,6 +142,7 @@ class ServerLogDrainsController extends Controller
new OA\Property(property: 'logdrain_newrelic_base_uri', type: 'string'),
new OA\Property(property: 'is_logdrain_axiom_enabled', type: 'boolean'),
new OA\Property(property: 'logdrain_axiom_dataset_name', type: 'string'),
new OA\Property(property: 'logdrain_axiom_domain', type: 'string', description: 'Base domain of the Axiom edge deployment. Default: us-east-1.aws.edge.axiom.co. Use eu-central-1.aws.edge.axiom.co for EU datasets.'),
new OA\Property(property: 'logdrain_axiom_api_key', type: 'string'),
new OA\Property(property: 'is_logdrain_custom_enabled', type: 'boolean'),
new OA\Property(property: 'logdrain_custom_config', type: 'string'),
@@ -185,6 +190,7 @@ class ServerLogDrainsController extends Controller
'is_logdrain_axiom_enabled' => 'boolean',
'logdrain_axiom_dataset_name' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logdrain_axiom_api_key' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logdrain_axiom_domain' => ['nullable', 'string', new ValidHostname],
'is_logdrain_custom_enabled' => 'boolean',
'logdrain_custom_config' => ['nullable', 'string', new ValidFluentBitConfig],
'logdrain_custom_config_parser' => ['nullable', 'string', new ValidFluentBitConfig(parsersOnly: true)],
@@ -250,7 +256,7 @@ class ServerLogDrainsController extends Controller
}
if ($settings->is_logdrain_cloudwatch_enabled) {
if ($settings->is_logdrain_newrelic_enabled || $settings->is_logdrain_axiom_enabled || $settings->is_logdrain_custom_enabled || $settings->is_logdrain_highlight_enabled) {
if ($settings->is_logdrain_newrelic_enabled || $settings->is_logdrain_axiom_enabled || $settings->is_logdrain_custom_enabled) {
return response()->json([
'message' => 'Validation failed.',
'errors' => [
@@ -533,8 +533,7 @@ class ServiceApplicationsController extends Controller
$containerName = $serviceApplication->name.'-'.$serviceApplication->service->uuid;
$status = getContainerStatus($server, $containerName);
if ($status !== 'running') {
if (! is_array(getContainerStatus($server, $containerName, all_data: true))) {
return response()->json([
'message' => 'Service application container is not running.',
], 400);
@@ -405,7 +405,7 @@ class ServiceDatabasesController extends Controller
[$serviceDatabase, $server] = $resolved;
$containerName = $serviceDatabase->name.'-'.$serviceDatabase->service->uuid;
if (getContainerStatus($server, $containerName) !== 'running') {
if (! is_array(getContainerStatus($server, $containerName, all_data: true))) {
return response()->json(['message' => 'Service database container is not running.'], 400);
}
@@ -22,6 +22,7 @@ use App\Support\ValidationPatterns;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Arr;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Validator;
use OpenApi\Attributes as OA;
@@ -302,7 +303,7 @@ class ServicesController extends Controller
mediaType: 'application/json',
schema: new OA\Schema(
type: 'object',
required: ['server_uuid', 'project_uuid', 'environment_name', 'environment_uuid'],
required: ['server_uuid', 'project_uuid'],
properties: [
'type' => ['description' => 'The one-click service type (e.g. "actualbudget", "calibre-web", "gitea-with-mysql" ...)', 'type' => 'string'],
'name' => ['type' => 'string', 'maxLength' => 255, 'description' => 'Name of the service.'],
@@ -993,13 +994,6 @@ class ServicesController extends Controller
return response()->json(['message' => 'Container not found.'], 404);
}
$status = getContainerStatus($service->destination->server, $container['Names']);
if ($status !== 'running') {
return response()->json([
'message' => 'Container is not running.',
], 400);
}
$lines = normalizeLogLines($request->query('lines'));
$showTimestamps = parseLogTimestampFlag($request->query('show_timestamps'));
$logs = sanitizeLogsForExport(
@@ -1702,6 +1696,7 @@ class ServicesController extends Controller
$updatedEnvs = collect();
foreach ($bulk_data as $item) {
$item = Arr::only($item, ['key', 'value', 'is_literal', 'is_multiline', 'is_shown_once', 'comment']);
if (array_key_exists('key', $item)) {
$item['key'] = ValidationPatterns::normalizeEnvironmentVariableKey((string) $item['key']);
}
@@ -3345,7 +3340,7 @@ class ServicesController extends Controller
$uuid = new_public_id();
$name = $request->filled('name')
? $request->string('name')->toString()
: $service->name.'-clone-'.$uuid;
: clone_resource_name($service->name, $uuid);
$cloneVolumeData = $request->boolean('clone_volumes', false);
$newService = clone_service($service, $destination, [
+15 -8
View File
@@ -2,11 +2,12 @@
namespace App\Http\Controllers\Api;
use App\Actions\Server\ValidateServer;
use App\Enums\ProxyTypes;
use App\Exceptions\CloudProviderApiException;
use App\Exceptions\RateLimitException;
use App\Http\Controllers\Api\Concerns\RequiresDeployAbility;
use App\Http\Controllers\Controller;
use App\Jobs\ValidateNewCloudServerJob;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
@@ -193,6 +194,7 @@ class VultrController extends Controller
new OA\Response(response: 403, description: 'The token needs the deploy permission for a cloud-init script or extra SSH keys.'),
new OA\Response(response: 422, description: 'Validation failed.'),
new OA\Response(response: 429, description: 'Vultr API rate limit exceeded.'),
new OA\Response(response: 502, ref: '#/components/responses/502'),
]
)]
public function createServer(Request $request): JsonResponse
@@ -275,8 +277,12 @@ class VultrController extends Controller
if (is_null($request->vultr_ssh_key_ids)) {
$request->offsetSet('vultr_ssh_key_ids', []);
}
if (is_null($request->instant_validate)) {
$request->offsetSet('instant_validate', false);
$request->offsetSet('instant_validate', $request->filled('instant_validate') ? $request->boolean('instant_validate') : $this->hasDeployAbility($request));
if ($request->instant_validate) {
$forbidden = $this->deployAbilityForbiddenResponse($request, 'Instant validation installs Docker and starts the proxy on the new server, so it needs a token with the deploy permission.');
if ($forbidden) {
return $forbidden;
}
}
if ($request->disable_public_ipv4 && ! $request->enable_ipv6) {
@@ -371,7 +377,7 @@ class VultrController extends Controller
}
if ($request->instant_validate) {
ValidateServer::dispatch($server);
ValidateNewCloudServerJob::dispatch($server);
}
auditLog('api.vultr_server.created', [
@@ -403,12 +409,13 @@ class VultrController extends Controller
}
return $response;
} catch (\Throwable $e) {
} catch (CloudProviderApiException $e) {
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
logger()->error('Failed to create Vultr server', [
'error' => $e->getMessage(),
]);
return $e->toJsonResponse('Failed to create Vultr server.');
} catch (\Throwable $e) {
$this->deleteUntrackedInstance($vultrService, $vultrInstanceId, $server);
report($e);
return response()->json(['message' => 'Failed to create Vultr server.'], 500);
}
-2
View File
@@ -735,7 +735,6 @@ class Github extends Controller
$data = Http::GitSource($api_url)->withBody(null)
->accept('application/vnd.github+json')
->timeout(10)
->connectTimeout(5)
->post("$api_url/app-manifests/$code/conversions")
->throw()
->json();
@@ -841,7 +840,6 @@ class Github extends Controller
'Accept' => 'application/vnd.github+json',
])
->timeout(10)
->connectTimeout(5)
->get("{$github_app->api_url}/app/installations/{$installation_id}");
} catch (\Throwable $e) {
return strip_tags(str_replace('<br>', ' ', $e->getMessage()));
+1 -1
View File
@@ -18,7 +18,7 @@ class ApiAllowed
$isMcpRestBridge = $request->attributes->get('mcp_rest_bridge') === true;
if (! $isMcpRestBridge && instanceSettings()->is_api_enabled === false) {
return response()->json(['success' => true, 'message' => 'API is disabled.'], 403);
return response()->json(['success' => false, 'message' => 'API is disabled.'], 403);
}
return app(ApiIpAllowed::class)->handle($request, $next);
+1 -1
View File
@@ -23,7 +23,7 @@ class ApiIpAllowed
$allowedIps = array_filter(array_map('trim', explode(',', $allowedIps)));
if (! empty($allowedIps) && ! checkIPAgainstAllowlist(client_ip($request), $allowedIps)) {
return response()->json(['success' => true, 'message' => 'You are not allowed to access the API.'], 403);
return response()->json(['success' => false, 'message' => 'You are not allowed to access the API.'], 403);
}
return $next($request);
+43 -21
View File
@@ -810,14 +810,14 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
$fileStorage->is_directory = true;
$fileStorage->content = null;
$fileStorage->save();
$fileStorage->deleteStorageOnServer($this->mainServer);
$fileStorage->saveStorageOnServer($this->mainServer);
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer);
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
} elseif ($fileStat->value() === 'regular file' && $fileStorage->is_directory) {
$fileStorage->is_directory = false;
$fileStorage->is_based_on_git = true;
$fileStorage->save();
$fileStorage->deleteStorageOnServer($this->mainServer);
$fileStorage->saveStorageOnServer($this->mainServer);
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->deleteStorageOnServer($this->mainServer);
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
}
}
}
@@ -849,12 +849,6 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return $service;
});
$composeFile['services'] = $services->toArray();
if (empty($composeFile)) {
$this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.');
$this->fail('Failed to parse docker-compose file.');
return;
}
// Add build secrets to compose file if enabled and BuildKit is supported
if ($this->dockerSecretsSupported && ! empty($this->build_secrets)) {
@@ -994,12 +988,25 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
/**
* Parses the Compose file for this deployment and writes the volume warnings of the parser
* (for example an external volume that the resource does not use yet) to the deployment log.
* A deployment of the application also queues one sync of the file storages with the server, which
* loads the content of the files that come from the repository. A preview uses its own copies of the
* files, so its deployment does not sync the storages of the application.
* The parser gives no services when it cannot parse the file. The deployment stops then, because
* an empty Compose file fails later with the unclear Docker error "no service selected".
*
* @return Collection<array-key, mixed>
*
* @throws DeploymentException When the parsed Compose file has no services
*/
private function parseComposeFileForDeployment(): Collection
{
$composeFile = collect($this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'), commit: $this->commit));
if (blank(data_get($composeFile, 'services'))) {
throw new DeploymentException("The Docker Compose file has no services, or Coolify could not parse it. Check that {$this->application->docker_compose_location} is valid YAML and has a services section.");
}
if ($this->pull_request_id === 0) {
ServerFilesFromServerJob::dispatch($this->application);
}
foreach ($this->application->composeVolumeWarnings() as $warning) {
$this->application_deployment_queue->addLogEntry("Warning: {$warning}", 'stderr');
}
@@ -1158,6 +1165,18 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
}
/**
* The pull request whose file storage paths this deployment writes. Only the current Compose parser
* mounts the `-pr-<id>` copies in a preview; other build packs and the legacy parser mount the
* paths of the application.
*/
private function previewFileStoragePullRequestId(): int
{
return $this->application->build_pack === 'dockercompose' && (int) $this->application->compose_parsing_version >= 3
? $this->pull_request_id
: 0;
}
/**
* The queued ServerStorageSaveJob writes new content files, but it can run after the containers
* start. Preserve-repository deployments write all file storages in write_deployment_configurations().
@@ -1171,7 +1190,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
EnsureContentFilesOnServer::run(
$this->application->fileStorages()->get(),
$this->application->fileStorages()->get()->map->forPullRequest($this->previewFileStoragePullRequestId()),
$this->mainServer,
function (string $message, string $type): void {
$this->application_deployment_queue->addLogEntry($message, $type);
@@ -1334,7 +1353,7 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
}
foreach ($this->application->fileStorages as $fileStorage) {
if (! $fileStorage->is_host_file && ! $fileStorage->is_based_on_git && ! $fileStorage->is_directory) {
$fileStorage->saveStorageOnServer($this->mainServer);
$fileStorage->forPullRequest($this->previewFileStoragePullRequestId())->saveStorageOnServer($this->mainServer);
}
}
if ($this->use_build_server) {
@@ -3332,18 +3351,21 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
return 'GIT_SSH_COMMAND="'.$sshCommand.'" '.$gitCommand.' ls-remote '.escapeshellarg($this->fullRepoUrl).' '.escapeshellarg($lsRemoteRef);
}
private function update_repository_project_id(): void
{
$repositorySlug = gitRepositorySlug($this->customRepository);
$repository = githubApi($this->source, "repos/{$repositorySlug}");
$repository_project_id = data_get($repository, 'data.id');
if (isset($repository_project_id) && $this->application->repository_project_id !== $repository_project_id) {
$this->application->repository_project_id = $repository_project_id;
$this->application->save();
}
}
private function check_git_if_build_needed()
{
if (is_object($this->source) && $this->source->getMorphClass() === GithubApp::class && $this->source->is_public === false) {
$repository = githubApi($this->source, "repos/{$this->customRepository}");
$data = data_get($repository, 'data');
$repository_project_id = data_get($data, 'id');
if (isset($repository_project_id)) {
if (blank($this->application->repository_project_id) || $this->application->repository_project_id !== $repository_project_id) {
$this->application->repository_project_id = $repository_project_id;
$this->application->save();
}
}
$this->update_repository_project_id();
}
$this->resolve_bitbucket_pull_request_commit();
$this->generate_git_import_commands();
+3 -3
View File
@@ -70,7 +70,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
private function update_comment()
{
['data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'patch', data: [
['data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'patch', data: [
'body' => $this->body,
], throwError: false);
if (data_get($data, 'message') === 'Not Found') {
@@ -80,7 +80,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
private function create_comment()
{
['data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/{$this->preview->pull_request_id}/comments", method: 'post', data: [
['data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/{$this->preview->pull_request_id}/comments", method: 'post', data: [
'body' => $this->body,
]);
$this->preview->pull_request_issue_comment_id = $data['id'];
@@ -89,7 +89,7 @@ class ApplicationPullRequestUpdateJob implements ShouldBeEncrypted, ShouldQueue
private function delete_comment()
{
githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'delete');
githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository)."/issues/comments/{$this->preview->pull_request_issue_comment_id}", method: 'delete');
}
private function getPreviewLinks(): string
+1 -1
View File
@@ -970,7 +970,7 @@ class DatabaseBackupJob implements ShouldBeEncrypted, ShouldQueue
private function setMinioClientAlias(Server $server, string $container, S3Storage $s3, string $resolveOptions): void
{
$remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($container)
.' mc alias set'.$resolveOptions.' temporary '.escapeshellarg($s3->endpoint).' >/dev/null';
.' mc alias set'.$resolveOptions.$s3->minioClientPathOption().' temporary '.escapeshellarg($s3->endpoint).' >/dev/null';
$timeout = (int) config('constants.ssh.command_timeout');
SshRetryHandler::retry(
+14 -2
View File
@@ -116,6 +116,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
public bool $foundLogDrainContainer = false;
public array $logDrainContainerLabels = [];
private ?array $cachedDestinationIds = null;
public function middleware(): array
@@ -275,6 +277,7 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
$name = data_get($container, 'name');
if ($name === 'coolify-log-drain' && $this->isRunning($containerStatus)) {
$this->foundLogDrainContainer = true;
$this->logDrainContainerLabels = $labels->all();
}
// Containers are matched by owner UUID; numeric ids differ between instances.
if (isContainerOfType($labels, 'application')) {
@@ -614,7 +617,9 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
->where('restart_limit_reached', false)
->update(['restart_limit_reached' => true]) === 1;
if ($restartLimitClaimed) {
// Stop again while a container still runs: an earlier stop can fail or come too late.
$stillRunning = $containerStatuses->contains(fn (string $status): bool => ! str($status)->contains('exited'));
if ($restartLimitClaimed || $stillRunning) {
$application->refresh();
StopApplication::dispatch(
application: $application,
@@ -623,6 +628,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
resetRestartCount: false,
removeContainers: false,
);
}
if ($restartLimitClaimed) {
$application->environment->project->team?->notify(new ApplicationRestartLimitReached($application));
}
}
@@ -860,6 +867,8 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
if (! $tcpProxyContainerFound) {
StartDatabaseProxy::dispatch($database);
$this->server->team?->notify(new ContainerRestarted("TCP Proxy for {$database->name}", $this->server, restartedResource: $database));
} elseif (StartDatabaseProxy::claimOutdatedRefresh($databaseUuid, data_get($tcpProxyContainerFound, 'labels'))) {
StartDatabaseProxy::dispatch($database);
}
} elseif ($this->isRunning($containerStatus) && ! $tcpProxy) {
// Clean up orphaned proxy containers when is_public=false
@@ -973,7 +982,10 @@ class PushServerUpdateJob implements ShouldBeEncrypted, ShouldQueue, Silenced
private function checkLogDrainContainer()
{
if ($this->server->isFluentBitLogDrainEnabled() && $this->foundLogDrainContainer === false) {
if (! $this->server->isFluentBitLogDrainEnabled()) {
return;
}
if ($this->foundLogDrainContainer === false || StartLogDrain::claimOutdatedRefresh($this->server, $this->logDrainContainerLabels)) {
StartLogDrain::dispatch($this->server);
}
}
+1 -1
View File
@@ -113,7 +113,7 @@ class ServerCheckJob implements ShouldBeEncrypted, ShouldQueue
})->first();
if ($foundLogDrainContainer) {
$status = data_get($foundLogDrainContainer, 'State.Status');
if ($status !== 'running') {
if ($status !== 'running' || StartLogDrain::claimOutdatedRefresh($this->server, data_get($foundLogDrainContainer, 'Config.Labels'))) {
StartLogDrain::dispatch($this->server);
}
} else {
+1 -19
View File
@@ -33,25 +33,7 @@ class ServerCloudProviderStatusCheckJob implements ShouldBeEncrypted, ShouldQueu
public function handle(): void
{
try {
if (! $this->server->cloudProviderToken) {
return;
}
match ($this->server->cloudProviderToken->provider) {
'hetzner' => $this->server->hetzner_server_id
? $this->server->refreshHetznerState()
: null,
'vultr' => $this->server->vultr_instance_id
? $this->server->refreshVultrState()
: null,
'digitalocean' => $this->server->digitalocean_droplet_id
? $this->server->refreshDigitalOceanState()
: null,
'hostinger' => $this->server->hostinger_virtual_machine_id
? $this->server->refreshHostingerState()
: null,
default => null,
};
$this->server->refreshCloudProviderState();
} catch (\Throwable $e) {
Log::debug('Cloud provider status check failed', [
'server_id' => $this->server->id,
+1 -2
View File
@@ -18,8 +18,7 @@ class ServerFilesFromServerJob implements ShouldBeEncrypted, ShouldBeUniqueUntil
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
/**
* Only one sync per resource can wait in the queue. The compose parser dispatches
* this job once per volume on every parse, so without this the queue fills with copies.
* Only one sync per resource can wait in the queue, also when deployments follow each other quickly.
*/
public int $uniqueFor = 600;
+75
View File
@@ -0,0 +1,75 @@
<?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\SerializesModels;
/**
* Waits until a server that Coolify just created at a cloud provider is running and accepts SSH,
* then validates it and installs Docker.
*/
class ValidateNewCloudServerJob implements ShouldBeEncrypted, ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public const MAX_ATTEMPTS = 40;
public const RETRY_DELAY_SECONDS = 30;
public $tries = 1;
public $timeout = 60;
public bool $deleteWhenMissingModels = true;
public function __construct(
public Server $server,
public int $attempt = 1
) {
$this->onQueue('high');
}
public function handle(): void
{
if (! $this->server->canBeValidated() || $this->server->is_validating || $this->server->isFunctional()) {
return;
}
if ($this->isReady()) {
ValidateAndInstallServerJob::dispatch($this->server);
return;
}
if ($this->attempt >= self::MAX_ATTEMPTS) {
$this->server->update([
'validation_logs' => 'The server did not start or accept SSH connections within '.(self::MAX_ATTEMPTS * self::RETRY_DELAY_SECONDS / 60).' minutes after it was created. Validate it manually.',
]);
return;
}
self::dispatch($this->server, $this->attempt + 1)->delay(now()->addSeconds(self::RETRY_DELAY_SECONDS));
}
private function isReady(): bool
{
try {
$status = $this->server->refreshCloudProviderState();
} catch (\Throwable) {
return false;
}
if (! in_array($status, ['running', 'active'], true) || $this->server->hasPlaceholderIp()) {
return false;
}
return $this->server->validateConnection()['uptime'];
}
}
+1 -1
View File
@@ -487,7 +487,7 @@ class VolumeBackupJob implements ShouldBeEncrypted, ShouldQueue
private function setMinioClientAlias(Server $server, string $container, S3Storage $s3, string $resolveOptions): void
{
$remoteCommand = ($server->isNonRoot() ? 'sudo ' : '').'docker exec -i '.escapeshellarg($container)
.' mc alias set'.$resolveOptions.' temporary '.escapeshellarg($s3->endpoint).' >/dev/null';
.' mc alias set'.$resolveOptions.$s3->minioClientPathOption().' temporary '.escapeshellarg($s3->endpoint).' >/dev/null';
$timeout = (int) config('constants.ssh.command_timeout');
SshRetryHandler::retry(
+6 -1
View File
@@ -348,9 +348,14 @@ class Analytics extends Component
$appRows = [];
$hostTotals = [];
$clients = $servers->mapWithKeys(fn (Server $server) => [$server->uuid => $this->trafficClient($server)]);
if ($resource === null) {
SentinelTrafficClient::prefetchServerWideConcurrently($clients, $from, $to, $this->range, appsLimit: self::MAX_LEADERBOARD_APPS);
}
foreach ($servers as $server) {
try {
$client = $this->trafficClient($server);
$client = $clients[$server->uuid];
if ($resource !== null) {
// One server in Sentinel's resource scope is exact; a merge across servers
+1
View File
@@ -51,6 +51,7 @@ class Show extends Component
$this->destination->name = $this->name;
$changedFields = auditChangedFields($this->destination);
$this->destination->save();
$this->dispatch('name-changed', uuid: $this->destination->uuid, name: $this->destination->name);
if ($changedFields !== []) {
auditLog('ui.destination.updated', $this->auditContext([
'changed_fields' => $changedFields,
+2 -8
View File
@@ -321,16 +321,10 @@ class Domains extends Component
if (! $this->isCompose) {
$this->composeServices = [];
} elseif ($parseCompose) {
try {
$parsed = $this->application->parse() ?? [];
} catch (\Throwable) {
$parsed = [];
}
$this->composeServices = [];
$services = data_get($parsed, 'services', []);
foreach ($services as $serviceName => $service) {
foreach ($this->application->composeServicesWithoutParse() as $serviceName => $service) {
if (! isDatabaseImage(data_get($service, 'image'))) {
$this->composeServices[] = $serviceName;
$this->composeServices[] = (string) $serviceName;
}
}
}
+17 -3
View File
@@ -170,9 +170,9 @@ class General extends Component
'gitRepository' => 'required',
'gitBranch' => ['required', 'string', new ValidGitBranch],
'gitCommitSha' => ['nullable', 'string', 'regex:/^[a-zA-Z0-9][a-zA-Z0-9._\-\/]*$/'],
'installCommand' => ValidationPatterns::shellSafeCommandRules(),
'buildCommand' => ValidationPatterns::shellSafeCommandRules(),
'startCommand' => ValidationPatterns::shellSafeCommandRules(),
'installCommand' => $this->changedCommandRules('installCommand', 'install_command'),
'buildCommand' => $this->changedCommandRules('buildCommand', 'build_command'),
'startCommand' => $this->changedCommandRules('startCommand', 'start_command'),
'buildPack' => 'required',
'staticImage' => ['required', Rule::enum(StaticImageTypes::class)],
'baseDirectory' => array_merge(['required'], array_slice(ValidationPatterns::directoryPathRules(), 1)),
@@ -217,6 +217,19 @@ class General extends Component
];
}
/**
* Commands saved before they were validated stay valid until they change. The deployment escapes them as one
* argument, and the fields are hidden for build packs other than Nixpacks and Railpack, so a user cannot fix them.
*/
private function changedCommandRules(string $property, string $column): array
{
if (isset($this->application) && (string) $this->{$property} === (string) $this->application->getOriginal($column)) {
return ['nullable'];
}
return ValidationPatterns::shellSafeCommandRules();
}
protected function messages(): array
{
return array_merge(
@@ -973,6 +986,7 @@ class General extends Component
$this->application->custom_labels = base64_encode($this->customLabels);
$this->application->save();
$this->application->refresh();
$this->dispatch('name-changed', uuid: $this->application->uuid, name: $this->application->name);
$dnsCleanup->queueReleaseOfRemovedHostnames($this->application, $previousDnsHostnames, $this->application->team()->id);
$this->syncData();
if ($oldPortsExposes !== $this->application->ports_exposes || $oldCustomNetworkAliases !== $this->application->custom_network_aliases) {
@@ -98,7 +98,7 @@ class Previews extends Component
if (! $this->application->isGithubAppSource()) {
throw new \Exception('Loading pull requests is only supported for GitHub App sources.');
}
['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: "/repos/{$this->application->git_repository}/pulls");
['rate_limit_remaining' => $rate_limit_remaining, 'data' => $data] = githubApi(source: $this->application->source, endpoint: '/repos/'.gitRepositorySlug($this->application->git_repository).'/pulls');
$this->rate_limit_remaining = $rate_limit_remaining;
$this->pull_requests = $data->sortBy('number')->values();
} catch (\Throwable $e) {
+4 -3
View File
@@ -205,20 +205,21 @@ class Source extends Component
$this->dispatch('configurationChanged');
['repository' => $customRepository] = $this->application->customRepository();
$repositorySlug = gitRepositorySlug($customRepository);
$repository_project_id = null;
if ($sourceType === GithubApp::class) {
$repository = githubApi($source, "repos/{$customRepository}");
$repository = githubApi($source, "repos/{$repositorySlug}");
$repository_project_id = data_get($repository, 'data.id');
} elseif ($sourceType === GitlabApp::class) {
if ($source->isConnected()) {
$encoded = urlencode($customRepository);
$encoded = urlencode($repositorySlug);
$project = gitlabApi($source, "/projects/{$encoded}");
$repository_project_id = data_get($project, 'data.id');
}
} elseif ($sourceType === GiteaApp::class) {
if ($source->isConnected()) {
$repositoryPath = collect(explode('/', $customRepository, 2))->map(fn ($part) => rawurlencode($part))->implode('/');
$repositoryPath = collect(explode('/', $repositorySlug, 2))->map(fn ($part) => rawurlencode($part))->implode('/');
$repository = giteaApi($source, "/repos/{$repositoryPath}");
$repository_project_id = data_get($repository, 'data.id');
}
+1 -1
View File
@@ -63,7 +63,7 @@ class CloneMe extends Component
->where('team_id', $this->project->team_id)
->get()
->reject(fn ($server) => $server->isBuildServer());
$this->newName = str($this->project->name.'-clone-'.new_public_id())->slug();
$this->newName = str(clone_resource_name($this->project->name, new_public_id()))->slug();
}
public function toggleVolumeCloning(bool $value)
@@ -135,6 +135,7 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -122,6 +122,7 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
+1 -1
View File
@@ -648,7 +648,7 @@ class ImportForm extends Component
'secret' => $s3Storage->secret,
'bucket' => $s3Storage->bucket,
'endpoint' => $s3Storage->endpoint,
'use_path_style_endpoint' => true,
'use_path_style_endpoint' => $s3Storage->usesPathStyleEndpoint(),
'http' => SafeWebhookUrl::httpClientOptions($s3Storage->endpoint),
]);
@@ -126,6 +126,7 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -151,6 +151,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -143,6 +143,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -151,6 +151,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -167,6 +167,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -144,6 +144,7 @@ class General extends Component
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
@@ -81,6 +81,7 @@ class General extends Component
$this->database->custom_docker_run_options = $this->customDockerRunOptions;
$this->database->is_log_drain_enabled = $this->isLogDrainEnabled;
$this->database->save();
$this->dispatch('name-changed', uuid: $this->database->uuid, name: $this->database->name);
} else {
$this->name = $this->database->name;
$this->description = $this->database->description;
+1
View File
@@ -85,6 +85,7 @@ class Edit extends Component
'name' => $this->name,
'description' => $this->description,
]);
$this->dispatch('name-changed', uuid: $this->project->uuid, name: $this->project->name);
} else {
$this->name = $this->project->name;
$this->description = $this->project->description;
@@ -170,10 +170,12 @@ class GiteaPrivateRepository extends Component
'selected_repository_path' => $this->selected_repository_path,
'selected_branch_name' => $this->selected_branch_name,
'docker_compose_location' => $this->docker_compose_location,
'base_directory' => $this->base_directory,
], [
'selected_repository_path' => 'required|string',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
'docker_compose_location' => ValidationPatterns::filePathRules(),
'base_directory' => ValidationPatterns::directoryPathRules(),
]);
if ($validator->fails()) {
@@ -181,7 +183,7 @@ class GiteaPrivateRepository extends Component
}
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@@ -115,17 +115,22 @@ class GithubPrivateRepository extends Component
->where('is_public', false)
->whereNotNull('app_id')
->findOrFail($github_app_id);
$token = generateGithubInstallationToken($this->github_app);
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
if ($this->repositories->count() < $this->total_repositories_count) {
try {
$token = generateGithubInstallationToken($this->github_app);
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
while ($this->repositories->count() < $this->total_repositories_count) {
$this->page++;
$repositories = loadRepositoryByPage($this->github_app, $token, $this->page);
$this->total_repositories_count = $repositories['total_count'];
$this->repositories = $this->repositories->concat(collect($repositories['repositories']));
}
} catch (\Throwable $e) {
$this->repositories = collect();
handleError($e, $this);
return;
}
$this->repositories = $this->repositories->sortBy('name');
if ($this->repositories->count() > 0) {
@@ -141,12 +146,17 @@ class GithubPrivateRepository extends Component
$this->selected_repository_repo = data_get($repository, 'name');
$this->branches = collect();
$this->page = 1;
$this->loadBranchByPage();
if ($this->total_branches_count === 100) {
while ($this->total_branches_count === 100) {
$this->page++;
try {
do {
$this->loadBranchByPage();
}
$this->page++;
} while ($this->total_branches_count === 100);
} catch (\Throwable $e) {
$this->branches = collect();
$this->total_branches_count = 0;
handleError($e, $this);
return;
}
$this->branches = sortBranchesByPriority($this->branches);
$defaultBranch = data_get($repository, 'default_branch', 'main');
@@ -157,7 +167,7 @@ class GithubPrivateRepository extends Component
$this->detectRepository();
}
protected function loadBranchByPage()
protected function loadBranchByPage(): void
{
$token = generateGithubInstallationToken($this->github_app);
@@ -170,7 +180,9 @@ class GithubPrivateRepository extends Component
]);
$json = $response->json();
if ($response->status() !== 200) {
return $this->dispatch('error', $json['message']);
$error = data_get($json, 'message', "HTTP {$response->status()}");
throw new \RuntimeException("Failed to load branches of {$this->selected_repository_owner}/{$this->selected_repository_repo} with error: ".$error);
}
$this->total_branches_count = count($json);
@@ -195,11 +207,13 @@ class GithubPrivateRepository extends Component
'selected_repository_repo' => $this->selected_repository_repo,
'selected_branch_name' => $this->selected_branch_name,
'docker_compose_location' => $this->docker_compose_location,
'base_directory' => $this->base_directory,
], [
'selected_repository_owner' => 'required|string|regex:/^[a-zA-Z0-9\-_]+$/',
'selected_repository_repo' => 'required|string|regex:/^[a-zA-Z0-9\-_\.]+$/',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
'docker_compose_location' => ValidationPatterns::filePathRules(),
'base_directory' => ValidationPatterns::directoryPathRules(),
]);
if ($validator->fails()) {
@@ -40,9 +40,9 @@ class GithubPrivateRepositoryDeployKey extends Component
public ?string $publish_directory = null;
// In case of docker compose
public ?string $base_directory = null;
public ?string $base_directory = '/';
// In case of docker compose
public ?string $docker_compose_location = '/docker-compose.yaml';
// End of docker compose
@@ -71,6 +71,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => ValidationPatterns::directoryPathRules(),
'docker_compose_location' => ValidationPatterns::filePathRules(),
];
}
@@ -82,6 +83,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'is_static' => 'Is static',
'publish_directory' => 'Publish directory',
'build_pack' => 'Build pack',
'base_directory' => 'Base directory',
];
public function mount()
@@ -182,6 +184,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'destination_id' => $destination->id,
'destination_type' => $destination_class,
'private_key_id' => $privateKey->id,
'base_directory' => $this->base_directory,
];
} else {
$application_init = [
@@ -197,6 +200,7 @@ class GithubPrivateRepositoryDeployKey extends Component
'private_key_id' => $privateKey->id,
'source_id' => $this->git_source->id,
'source_type' => $this->git_source->getMorphClass(),
'base_directory' => $this->base_directory,
];
}
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
@@ -207,7 +211,6 @@ class GithubPrivateRepositoryDeployKey extends Component
}
if ($this->build_pack === 'dockercompose') {
$application_init['docker_compose_location'] = $this->docker_compose_location;
$application_init['base_directory'] = $this->base_directory;
}
$application = new Application($application_init);
$application->save();
@@ -165,10 +165,12 @@ class GitlabPrivateRepository extends Component
'selected_repository_path' => $this->selected_repository_path,
'selected_branch_name' => $this->selected_branch_name,
'docker_compose_location' => $this->docker_compose_location,
'base_directory' => $this->base_directory,
], [
'selected_repository_path' => 'required|string',
'selected_branch_name' => ['required', 'string', new ValidGitBranch],
'docker_compose_location' => ValidationPatterns::filePathRules(),
'base_directory' => ValidationPatterns::directoryPathRules(),
]);
if ($validator->fails()) {
@@ -176,7 +178,7 @@ class GitlabPrivateRepository extends Component
}
$destination_uuid = $this->query['destination'] ?? null;
$destination = find_destination_for_current_team($destination_uuid);
$destination = find_resource_destination_for_current_team($destination_uuid);
if (! $destination) {
throw new \Exception('Destination not found.');
}
@@ -73,7 +73,7 @@ class PublicGitRepository extends Component
'isStatic' => 'required|boolean',
'publish_directory' => 'nullable|string',
'build_pack' => 'required|string',
'base_directory' => 'nullable|string',
'base_directory' => ValidationPatterns::directoryPathRules(),
'docker_compose_location' => ValidationPatterns::filePathRules(),
'git_branch' => ['required', 'string', new ValidGitBranch],
];
@@ -351,7 +351,6 @@ class PublicGitRepository extends Component
}
if ($this->build_pack === 'dockercompose') {
$application_init['docker_compose_location'] = $this->docker_compose_location;
$application_init['base_directory'] = $this->base_directory;
}
$application = new Application($application_init);
$application->save();
+6 -28
View File
@@ -168,8 +168,6 @@ class Index extends Component
return;
}
$this->parameters = get_route_parameters();
$this->query = request()->query();
$this->currentRoute = request()->route()->getName();
$project = currentTeam()
->projects()
->select('id', 'uuid', 'team_id')
@@ -181,32 +179,12 @@ class Index extends Component
->firstOrFail();
$this->service = $environment->services()->whereUuid($this->parameters['service_uuid'])->firstOrFail();
$this->authorize('view', $this->service);
if (in_array($this->currentRoute, ['project.service.index', 'project.service.index.advanced'], true)) {
return redirect()->route(
'project.service.configuration',
collect($this->parameters)->except('stack_service_uuid')->all(),
);
}
$service = $this->service->applications()->whereUuid($this->parameters['stack_service_uuid'])->first();
if ($service) {
$this->serviceApplication = $service;
$this->resourceType = 'application';
$this->serviceApplication->getFilesFromServer();
$this->initializeApplicationProperties();
} else {
$this->serviceDatabase = $this->service->databases()->whereUuid($this->parameters['stack_service_uuid'])->first();
if (! $this->serviceDatabase) {
return redirect()->route('project.service.configuration', [
'project_uuid' => $this->parameters['project_uuid'],
'environment_uuid' => $this->parameters['environment_uuid'],
'service_uuid' => $this->parameters['service_uuid'],
]);
}
$this->resourceType = 'database';
$this->serviceDatabase->getFilesFromServer();
$this->initializeDatabaseProperties();
}
$this->s3s = $this->serviceTeamS3Storages();
// The settings of a stack service open in a modal on the configuration page.
return redirect()->route(
'project.service.configuration',
collect($this->parameters)->except('stack_service_uuid')->all(),
);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -199,6 +199,7 @@ class StackForm extends Component
// Refresh and write files after a successful commit
$this->service->refresh();
$this->service->saveComposeConfigs();
$this->dispatch('name-changed', uuid: $this->service->uuid, name: $this->service->name);
$this->dispatch('refreshEnvs')->to(EditCompose::class);
$this->dispatch('refreshEnvs')->to(All::class);
@@ -986,23 +986,12 @@ class All extends Component
private function formatEnvironmentVariables($variables)
{
$hideValues = ! $this->canViewEnvironmentValues();
$canViewValues = $this->canViewEnvironmentValues();
return $variables
->reject(fn ($item): bool => $this->isProtectedEnvironmentVariable($item->key))
->map(function ($item) use ($hideValues) {
if ($hideValues) {
return "$item->key=(Hidden, only admins can view)";
}
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))
->join("\n");
}
public function switch()
@@ -1017,11 +1006,28 @@ class All extends Component
public function submit($data = null)
{
// Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text.
try {
$this->authorize('manageEnvironment', $this->resource);
$this->ensureEnvironmentVariablesLoaded();
if ($data === null) {
$this->handleBulkSubmit();
$variables = $this->normalizeEnvironmentVariables(parseEnvDevView($this->variables, EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS));
$previewVariables = [];
if ($this->showPreview) {
try {
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvDevView($this->variablesPreview, EnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS));
} catch (\InvalidArgumentException $e) {
throw new \InvalidArgumentException('Preview deployments: '.$e->getMessage(), previous: $e);
}
}
}
} catch (\Throwable $e) {
return handleError($e, $this);
}
try {
if ($data === null) {
$this->handleBulkSubmit($variables, $previewVariables);
} else {
$this->handleSingleSubmit($data);
}
@@ -1071,9 +1077,8 @@ class All extends Component
}
}
private function handleBulkSubmit()
private function handleBulkSubmit(array $variables, array $previewVariables)
{
$variables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variables));
$changesMade = false;
$errorOccurred = false;
@@ -1092,8 +1097,6 @@ class All extends Component
}
if ($this->showPreview) {
$previewVariables = $this->normalizeEnvironmentVariables(parseEnvFormatToArray($this->variablesPreview));
// Try to delete removed preview variables
$deletedPreviewCount = $this->deleteRemovedVariables(true, $previewVariables);
if ($deletedPreviewCount > 0) {
@@ -1223,7 +1226,7 @@ class All extends Component
$found = $this->resource->$method()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) {
$changed = false;
// Update value if it changed
@@ -1239,7 +1242,10 @@ class All extends Component
$changed = true;
}
if ($changed) {
// Flags change only when a "# coolify:" line sets them
$found->fill($data['settings']);
if ($changed || $found->isDirty()) {
$found->save();
$count++;
}
@@ -1250,6 +1256,7 @@ class All extends Component
$environment->value = $value;
$environment->comment = $comment; // Set comment from inline comment
$environment->is_multiline = false;
$environment->fill($data['settings']);
$environment->is_preview = $isPreview;
$environment->resourceable_id = $this->resource->id;
$environment->resourceable_type = $this->resource->getMorphClass();
+3 -2
View File
@@ -297,7 +297,7 @@ class GetLogs extends Component
$stripTimestamps ??= $export && ! $this->showTimeStamps;
$merged = $this->joinMergedLines($groups, $stripTimestamps, $export);
if (strlen($merged) > $maxBytes) {
$merged = rtrim(substr($merged, 0, $maxBytes), "\n");
$merged = rtrim(mb_strcut($merged, 0, $maxBytes, 'UTF-8'), "\n");
$truncated = true;
}
if ($truncated && $merged !== '') {
@@ -502,7 +502,8 @@ class GetLogs extends Component
->run(SshMultiplexingHelper::generateSshBatchCommand($server))
->output();
$truncated = strlen($output) > $maxBytes;
$logs = rtrim(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output), "\n");
// Invalid UTF-8 from a container would make the JSON response fail, so the viewer would stay empty.
$logs = rtrim(sanitize_utf8_text(removeAnsiColors($truncated ? substr($output, 0, $maxBytes) : $output)), "\n");
return ['logs' => $logs, 'truncated' => $truncated];
}
@@ -132,7 +132,7 @@ class ResourceOperations extends Component
'updated_at',
])->fill([
'uuid' => $uuid,
'name' => $this->resource->name.'-clone-'.$uuid,
'name' => clone_resource_name($this->resource->name, $uuid),
'status' => 'exited',
'started_at' => null,
'environment_id' => $new_environment->id,
@@ -44,9 +44,7 @@ class All extends Component
$this->containerNames = $this->containerNames->merge($this->resource->databases()->pluck('name'));
} elseif ($this->resource->type() === 'application') {
if ($this->resource->build_pack === 'dockercompose') {
$parsed = $this->resource->parse();
$containers = collect(data_get($parsed, 'services'))->keys();
$this->containerNames = $containers;
$this->containerNames = collect($this->resource->composeServicesWithoutParse())->keys();
} else {
$this->containerNames = collect([]);
}
@@ -6,6 +6,7 @@ use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
@@ -180,6 +181,8 @@ class IntegrationTokenEditor extends Component
capabilities: $this->integrationToken->capabilities,
);
$this->dispatch('success', 'Integration token updated successfully.');
} catch (ConnectionException) {
$this->dispatch('error', $validator->connectionErrorMessage($provider));
} catch (\Throwable $e) {
handleError($e, $this);
}
@@ -6,6 +6,7 @@ use App\Models\IntegrationToken;
use App\Services\Dns\CloudflareDnsProvider;
use App\Services\IntegrationTokenValidator;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\ConnectionException;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
@@ -150,6 +151,8 @@ class IntegrationTokenForm extends Component
}
$this->dispatch('success', 'Integration token added successfully.');
} catch (ConnectionException) {
$this->dispatch('error', $validator->connectionErrorMessage($validated['provider']));
} catch (\Throwable $e) {
handleError($e, $this);
}
+21 -5
View File
@@ -5,7 +5,9 @@ namespace App\Livewire\Server;
use App\Actions\Server\StartLogDrain;
use App\Actions\Server\StopLogDrain;
use App\Models\Server;
use App\Models\ServerSetting;
use App\Rules\ValidFluentBitConfig;
use App\Rules\ValidHostname;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Validation\ValidationException;
use Livewire\Attributes\Validate;
@@ -38,6 +40,9 @@ class LogDrains extends Component
#[Validate(['string', 'nullable', 'regex:/^[a-zA-Z0-9_\-\.]+$/'])]
public ?string $logDrainAxiomApiKey = null;
#[Validate(['string', 'nullable', new ValidHostname])]
public ?string $logDrainAxiomDomain = null;
#[Validate(['string', 'nullable'])]
public ?string $logDrainCustomConfig = null;
@@ -87,9 +92,11 @@ class LogDrains extends Component
$this->server->settings->is_logdrain_axiom_enabled = $this->isLogDrainAxiomEnabled;
$this->server->settings->logdrain_axiom_dataset_name = $this->logDrainAxiomDatasetName;
$this->server->settings->logdrain_axiom_api_key = $this->logDrainAxiomApiKey;
$this->server->settings->logdrain_axiom_domain = $this->logDrainAxiomDomain;
} else {
$this->isLogDrainAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
$this->logDrainAxiomDatasetName = $this->server->settings->logdrain_axiom_dataset_name;
$this->logDrainAxiomDomain = $this->server->settings->logdrain_axiom_domain ?: ServerSetting::DEFAULT_AXIOM_DOMAIN;
$this->logDrainAxiomApiKey = auth()->user()->can('update', $this->server)
? $this->server->settings->logdrain_axiom_api_key
: null;
@@ -146,9 +153,6 @@ class LogDrains extends Component
$this->syncDataCustom($toModel);
$this->syncDataCloudwatch($toModel);
}
if ($this->enabledLogDrainCount() > 0) {
$this->server->settings->is_logdrain_highlight_enabled = false;
}
$this->auditLogDrain('updated');
$this->server->settings->save();
} else {
@@ -195,6 +199,7 @@ class LogDrains extends Component
$this->validate([
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomDomain' => ['required', new ValidHostname],
]);
} catch (\Throwable $e) {
$this->isLogDrainAxiomEnabled = false;
@@ -240,6 +245,8 @@ class LogDrains extends Component
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (ValidationException $e) {
return $this->handleValidationError($e);
} catch (\Throwable $e) {
return handleError($e, $this);
}
@@ -251,7 +258,6 @@ class LogDrains extends Component
$previousAxiomEnabled = $this->server->settings->is_logdrain_axiom_enabled;
$previousCustomEnabled = $this->server->settings->is_logdrain_custom_enabled;
$previousCloudwatchEnabled = $this->server->settings->is_logdrain_cloudwatch_enabled;
$previousHighlightEnabled = $this->server->settings->is_logdrain_highlight_enabled;
try {
$this->authorize('update', $this->server);
@@ -279,6 +285,9 @@ class LogDrains extends Component
StopLogDrain::run($this->server);
$this->dispatch('success', 'Log drain service stopped.');
}
} catch (ValidationException $e) {
// Validation runs before anything is saved, so keep the typed values and show the errors under the fields.
$this->handleValidationError($e);
} catch (\Throwable $e) {
// Restore the previously persisted enabled flags so the UI/DB never
// claim a runtime state that the Start/StopLogDrain action failed to apply.
@@ -286,7 +295,6 @@ class LogDrains extends Component
$this->server->settings->is_logdrain_axiom_enabled = $previousAxiomEnabled;
$this->server->settings->is_logdrain_custom_enabled = $previousCustomEnabled;
$this->server->settings->is_logdrain_cloudwatch_enabled = $previousCloudwatchEnabled;
$this->server->settings->is_logdrain_highlight_enabled = $previousHighlightEnabled;
$this->server->settings->save();
$this->syncData();
@@ -310,6 +318,13 @@ class LogDrains extends Component
return view('livewire.server.log-drains');
}
private function handleValidationError(ValidationException $e): mixed
{
$this->setErrorBag($e->validator->errors());
return handleError($e, $this);
}
private function enabledLogDrainCount(): int
{
return count(array_filter([
@@ -351,6 +366,7 @@ class LogDrains extends Component
'axiom' => $this->validate([
'logDrainAxiomDatasetName' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomApiKey' => ['required', 'regex:/^[a-zA-Z0-9_\-\.]+$/'],
'logDrainAxiomDomain' => ['required', new ValidHostname],
]),
'custom' => $this->validate([
'logDrainCustomConfig' => ['required', new ValidFluentBitConfig],
@@ -3,6 +3,7 @@
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Jobs\ValidateNewCloudServerJob;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
@@ -519,6 +520,8 @@ class ByDigitalOcean extends Component
report($e);
}
ValidateNewCloudServerJob::dispatch($server);
if ($this->from_onboarding) {
currentTeam()->update([
'show_boarding' => false,
+43 -22
View File
@@ -3,6 +3,8 @@
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Exceptions\CloudProviderApiException;
use App\Jobs\ValidateNewCloudServerJob;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
@@ -12,7 +14,6 @@ use App\Rules\ValidCloudInitYaml;
use App\Rules\ValidHostname;
use App\Services\HetznerService;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Http\Client\RequestException;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Http;
use Illuminate\Validation\ValidationException;
@@ -323,7 +324,7 @@ class ByHetzner extends Component
$hetznerService = new HetznerService($token);
$this->locations = $hetznerService->getLocations();
$this->serverTypes = $hetznerService->getServerTypes();
$this->serverTypes = $hetznerService->getServerTypes(includeOutOfStock: true);
// Get images and sort by name
$images = $hetznerService->getImages();
@@ -358,22 +359,13 @@ class ByHetzner extends Component
$this->loading_data = false;
} catch (\Throwable $e) {
$this->loading_data = false;
$this->provider_data_error = $this->providerDataErrorMessage('Hetzner', $e, 'error.message');
$this->provider_data_error = $e instanceof CloudProviderApiException
? $e->getMessage()
: 'Hetzner API error: '.$e->getMessage();
$this->dispatch('error', $this->provider_data_error);
}
}
private function providerDataErrorMessage(string $providerName, \Throwable $e, string $jsonMessageKey): string
{
$details = $e->getMessage();
if ($e instanceof RequestException && $e->response) {
$details = data_get($e->response->json(), $jsonMessageKey) ?: $e->response->body() ?: $details;
}
return "{$providerName} API error: {$details}";
}
private function getCpuVendorInfo(array $serverType): ?string
{
$name = strtolower($serverType['name'] ?? '');
@@ -394,7 +386,7 @@ class ByHetzner extends Component
public function getAvailableServerTypesProperty()
{
if (! $this->selected_location) {
return $this->serverTypes;
return [];
}
$filtered = collect($this->serverTypes)
@@ -409,15 +401,24 @@ class ByHetzner extends Component
})
->map(function ($serverType) {
$serverType['cpu_vendor_info'] = $this->getCpuVendorInfo($serverType);
$serverType['out_of_stock'] = $this->isServerTypeOutOfStock($serverType);
return $serverType;
})
->sortBy('out_of_stock')
->values()
->toArray();
return $filtered;
}
private function isServerTypeOutOfStock(array $serverType): bool
{
$typeLocation = collect($serverType['locations'] ?? [])->firstWhere('name', $this->selected_location);
return ($typeLocation['available'] ?? true) === false;
}
public function getAvailableImagesProperty()
{
@@ -679,6 +680,13 @@ class ByHetzner extends Component
{
$this->validate();
$selectedServerType = collect($this->serverTypes)->firstWhere('name', $this->selected_server_type);
if ($selectedServerType && $this->isServerTypeOutOfStock($selectedServerType)) {
$this->addError('selected_server_type', 'This server type is out of stock in the selected location. Select a different server type or location.');
return null;
}
if (! $this->enable_ipv4 && ! $this->enable_ipv6) {
$this->addError('enable_ipv4', 'Enable at least one public IP protocol.');
$this->addError('enable_ipv6', 'Enable at least one public IP protocol.');
@@ -751,6 +759,8 @@ class ByHetzner extends Component
}
}
ValidateNewCloudServerJob::dispatch($server);
if ($this->from_onboarding) {
// Complete the boarding when server is successfully created via Hetzner
currentTeam()->update([
@@ -766,20 +776,31 @@ class ByHetzner extends Component
if (! isset($e->errors()['server'])) {
throw $e;
}
if (isset($hetznerService, $hetznerServer['id'])) {
try {
$hetznerService->deleteServer((int) $hetznerServer['id']);
} catch (\Throwable $cleanupError) {
report($cleanupError);
}
}
$this->deleteUntrackedHetznerServer($hetznerService ?? null, $hetznerServer['id'] ?? null);
return $this->dispatch('error', 'You have reached the server limit for your subscription.');
} catch (\Throwable $e) {
if (! isset($server)) {
$this->deleteUntrackedHetznerServer($hetznerService ?? null, $hetznerServer['id'] ?? null);
}
return handleError($e, $this);
}
}
private function deleteUntrackedHetznerServer(?HetznerService $hetznerService, ?int $hetznerServerId): void
{
if (! $hetznerService || ! $hetznerServerId) {
return;
}
try {
$hetznerService->deleteServer($hetznerServerId);
} catch (\Throwable $e) {
report($e);
}
}
public function render()
{
return view('livewire.server.new.by-hetzner');
+3
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Jobs\ValidateNewCloudServerJob;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
use App\Models\Server;
@@ -377,6 +378,8 @@ class ByHostinger extends Component
report($e);
}
ValidateNewCloudServerJob::dispatch($server);
if ($this->from_onboarding) {
currentTeam()->update(['show_boarding' => false]);
refreshSession();
+3
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\Server\New;
use App\Enums\ProxyTypes;
use App\Jobs\ValidateNewCloudServerJob;
use App\Models\CloudInitScript;
use App\Models\CloudProviderToken;
use App\Models\PrivateKey;
@@ -487,6 +488,8 @@ class ByVultr extends Component
report($e);
}
ValidateNewCloudServerJob::dispatch($server);
if ($this->from_onboarding) {
currentTeam()->update([
'show_boarding' => false,
-12
View File
@@ -86,14 +86,6 @@ class Show extends Component
public ?string $hostingerVirtualMachineStatus = null;
public bool $hetznerServerManuallyStarted = false;
public bool $vultrInstanceManuallyStarted = false;
public bool $digitalOceanDropletManuallyStarted = false;
public bool $hostingerVirtualMachineManuallyStarted = false;
public bool $isValidating = false;
// Hetzner linking properties
@@ -780,7 +772,6 @@ class Show extends Component
$this->hetznerServerStatus = 'starting';
$this->server->update(['hetzner_server_status' => 'starting']);
$this->hetznerServerManuallyStarted = true; // Set flag to trigger auto-validation when running
$this->dispatch('success', 'Hetzner server is starting...');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -803,7 +794,6 @@ class Show extends Component
$this->vultrInstanceStatus = 'starting';
$this->server->update(['vultr_instance_status' => 'starting']);
$this->vultrInstanceManuallyStarted = true;
$this->dispatch('success', 'Vultr instance is starting...');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -826,7 +816,6 @@ class Show extends Component
$this->digitalOceanDropletStatus = 'new';
$this->server->update(['digitalocean_droplet_status' => 'new']);
$this->digitalOceanDropletManuallyStarted = true;
$this->dispatch('success', 'DigitalOcean droplet is starting...');
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -849,7 +838,6 @@ class Show extends Component
$this->hostingerVirtualMachineStatus = 'starting';
$this->server->update(['hostinger_virtual_machine_status' => 'starting']);
$this->hostingerVirtualMachineManuallyStarted = true;
$this->dispatch('success', 'Hostinger VPS is starting...');
return null;
@@ -116,6 +116,22 @@ class ValidateAndInstall extends Component
try {
$this->authorize('update', $this->server);
Server::throttleValidation($this->server->team_id, $this->server->ip);
if ($this->server->hetzner_server_id) {
$status = $this->server->refreshHetznerState();
$this->server->refresh();
if (in_array($status, ['initializing', 'starting', 'stopping', 'off', 'deleting', 'rebuilding', 'migrating'], true)) {
$this->error = $status === 'off'
? 'Hetzner server is off. Power it on before validating.'
: "Hetzner server is {$status}. Wait until it is running, then validate.";
$this->server->update([
'validation_logs' => $this->error,
]);
return;
}
}
if ($this->server->vultr_instance_id) {
$status = $this->server->refreshVultrState();
$this->server->refresh();
@@ -5,6 +5,7 @@ namespace App\Livewire\SharedVariables\Environment;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Application;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
@@ -83,26 +84,21 @@ class Show extends Component
{
$canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false;
return $variables->map(function ($item) use ($canViewValues) {
if (! $canViewValues) {
return "$item->key=(Hidden, only admins can view)";
}
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n");
}
public function submit()
{
// Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text.
try {
$this->authorize('update', $this->environment);
$this->handleBulkSubmit();
$variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS);
} catch (\Throwable $e) {
return handleError($e, $this);
}
try {
$this->handleBulkSubmit($variables);
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -111,9 +107,8 @@ class Show extends Component
}
}
private function handleBulkSubmit()
private function handleBulkSubmit(array $variables)
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = false;
DB::transaction(function () use ($variables, &$changesMade) {
@@ -158,9 +153,12 @@ class Show extends Component
$found = $this->environment->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) {
if ($found->value !== $value) {
$found->value = $value;
}
$found->fill($data['settings']);
if ($found->isDirty()) {
$found->save();
$count++;
}
@@ -171,6 +169,7 @@ class Show extends Component
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
...$data['settings'],
'type' => 'environment',
'team_id' => $this->project->team_id,
]);
+16 -18
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Project;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Project;
use App\Models\SharedEnvironmentVariable;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
@@ -81,26 +82,21 @@ class Show extends Component
{
$canViewValues = auth()->user()?->isAdminOfTeam($this->project->team_id) ?? false;
return $variables->map(function ($item) use ($canViewValues) {
if (! $canViewValues) {
return "$item->key=(Hidden, only admins can view)";
}
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n");
}
public function submit()
{
// Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text.
try {
$this->authorize('update', $this->project);
$this->handleBulkSubmit();
$variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS);
} catch (\Throwable $e) {
return handleError($e, $this);
}
try {
$this->handleBulkSubmit($variables);
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -109,10 +105,8 @@ class Show extends Component
}
}
private function handleBulkSubmit()
private function handleBulkSubmit(array $variables)
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = DB::transaction(function () use ($variables) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
@@ -150,9 +144,12 @@ class Show extends Component
$found = $this->project->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) {
if ($found->value !== $value) {
$found->value = $value;
}
$found->fill($data['settings']);
if ($found->isDirty()) {
$found->save();
$count++;
}
@@ -163,6 +160,7 @@ class Show extends Component
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
...$data['settings'],
'type' => 'project',
'team_id' => $this->project->team_id,
]);
+18 -20
View File
@@ -4,6 +4,7 @@ namespace App\Livewire\SharedVariables\Server;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\Server;
use App\Models\SharedEnvironmentVariable;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
use Livewire\Component;
@@ -82,26 +83,21 @@ class Show extends Component
{
$canViewValues = auth()->user()?->isAdminOfTeam($this->server->team_id) ?? false;
return $variables->map(function ($item) use ($canViewValues) {
if (! $canViewValues) {
return "$item->key=(Hidden, only admins can view)";
}
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n");
}
public function submit()
{
// Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text.
try {
$this->authorize('update', $this->server);
$this->handleBulkSubmit();
$variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS);
} catch (\Throwable $e) {
return handleError($e, $this);
}
try {
$this->handleBulkSubmit($variables);
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -110,10 +106,8 @@ class Show extends Component
}
}
private function handleBulkSubmit()
private function handleBulkSubmit(array $variables)
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = DB::transaction(function () use ($variables) {
// Delete removed variables
$deletedCount = $this->deleteRemovedVariables($variables);
@@ -162,10 +156,13 @@ class Show extends Component
$found = $this->server->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if ($found->value !== $value || $found->comment !== $comment) {
if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) {
if ($found->value !== $value) {
$found->value = $value;
$found->comment = $comment;
}
$found->comment = $comment;
$found->fill($data['settings']);
if ($found->isDirty()) {
$found->save();
$count++;
}
@@ -177,6 +174,7 @@ class Show extends Component
'comment' => $comment,
'is_multiline' => false,
'is_literal' => false,
...$data['settings'],
'type' => 'server',
'team_id' => $this->server->team_id,
]);
+16 -17
View File
@@ -3,6 +3,7 @@
namespace App\Livewire\SharedVariables\Team;
use App\Livewire\Concerns\RemembersCanvasView;
use App\Models\SharedEnvironmentVariable;
use App\Models\Team;
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
use Illuminate\Support\Facades\DB;
@@ -75,26 +76,21 @@ class Index extends Component
{
$canViewValues = auth()->user()?->isAdminOfTeam($this->team->id) ?? false;
return $variables->map(function ($item) use ($canViewValues) {
if (! $canViewValues) {
return "$item->key=(Hidden, only admins can view)";
}
if ($item->is_shown_once) {
return "$item->key=(Locked Secret, delete and add again to change)";
}
if ($item->is_multiline) {
return "$item->key=(Multiline environment variable, edit in normal view)";
}
return "$item->key=$item->value";
})->join("\n");
return $variables->map(fn ($item) => formatEnvVariableForDevView($item, $canViewValues))->join("\n");
}
public function submit()
{
// Parse before the save, so that an invalid "# coolify:" line changes nothing and keeps the edited text.
try {
$this->authorize('update', $this->team);
$this->handleBulkSubmit();
$variables = parseEnvDevView($this->variables, SharedEnvironmentVariable::DEV_VIEW_FLAG_DEFAULTS);
} catch (\Throwable $e) {
return handleError($e, $this);
}
try {
$this->handleBulkSubmit($variables);
$this->getDevView();
} catch (\Throwable $e) {
return handleError($e, $this);
@@ -103,9 +99,8 @@ class Index extends Component
}
}
private function handleBulkSubmit()
private function handleBulkSubmit(array $variables)
{
$variables = parseEnvFormatToArray($this->variables);
$changesMade = false;
DB::transaction(function () use ($variables, &$changesMade) {
@@ -149,9 +144,12 @@ class Index extends Component
$found = $this->team->environment_variables()->where('key', $key)->first();
if ($found) {
if (! $found->is_shown_once && ! $found->is_multiline) {
if (! $found->is_shown_once && ! $found->is_multiline && formatEnvValueForDevView($found->value) !== null) {
if ($found->value !== $value) {
$found->value = $value;
}
$found->fill($data['settings']);
if ($found->isDirty()) {
$found->save();
$count++;
}
@@ -162,6 +160,7 @@ class Index extends Component
'value' => $value,
'is_multiline' => false,
'is_literal' => false,
...$data['settings'],
'type' => 'team',
'team_id' => $this->team->id,
]);
+1
View File
@@ -282,6 +282,7 @@ class Change extends Component
$this->syncData(true);
$this->gitea_app->save();
$this->dispatch('success', 'Gitea App updated.');
$this->dispatch('name-changed', uuid: $this->gitea_app->uuid, name: $this->gitea_app->name);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+1
View File
@@ -431,6 +431,7 @@ class Change extends Component
$this->syncData(true);
$this->github_app->save();
$this->isConnected = $this->github_app->isConnected();
$this->dispatch('name-changed', uuid: $this->github_app->uuid, name: $this->github_app->name);
$this->dispatch('success', 'Github App updated.');
} catch (ValidationException $e) {
throw $e;
+1
View File
@@ -282,6 +282,7 @@ class Change extends Component
$this->syncData(true);
$this->gitlab_app->save();
$this->dispatch('success', 'GitLab App updated.');
$this->dispatch('name-changed', uuid: $this->gitlab_app->uuid, name: $this->gitlab_app->name);
} catch (\Throwable $e) {
return handleError($e, $this);
}
+15
View File
@@ -29,6 +29,10 @@ class Create extends Component
public string $endpoint = '';
public bool $usePathStyleEndpoint = true;
public bool $usePathStyleEndpointChanged = false;
public array $endpointParts = ['scheme' => 'https', 'host' => '', 'port' => '', 'path' => ''];
public bool $endpointPartsChanged = false;
@@ -45,6 +49,7 @@ class Create extends Component
'secret' => 'required|max:255',
'bucket' => ['required', new ValidS3BucketName],
'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
'usePathStyleEndpoint' => 'boolean',
];
}
@@ -74,6 +79,7 @@ class Create extends Component
'secret' => 'Secret',
'bucket' => 'Bucket',
'endpoint' => 'Endpoint',
'usePathStyleEndpoint' => 'Use path-style URLs',
];
public function submit()
@@ -93,6 +99,7 @@ class Create extends Component
$this->storage->key = $this->key;
$this->storage->secret = $this->secret;
$this->storage->bucket = $this->bucket;
$this->storage->use_path_style_endpoint = $this->usePathStyleEndpoint;
if (empty($this->endpoint)) {
$this->storage->endpoint = "https://s3.{$this->region}.amazonaws.com";
} else {
@@ -112,6 +119,14 @@ class Create extends Component
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
if (! $this->usePathStyleEndpointChanged) {
$this->usePathStyleEndpoint = S3Storage::detectPathStyleEndpoint(DomainUrlParts::compose(...$this->endpointParts));
}
}
public function updatedUsePathStyleEndpoint(): void
{
$this->usePathStyleEndpointChanged = true;
}
private function connectionErrorDescription(\Throwable $exception): string
+27 -2
View File
@@ -33,6 +33,10 @@ class Form extends Component
public string $region;
public bool $usePathStyleEndpoint = true;
public bool $usePathStyleEndpointChanged = false;
public string $key;
public string $secret;
@@ -52,6 +56,7 @@ class Form extends Component
'secret' => 'required|max:255',
'bucket' => ['required', new ValidS3BucketName],
'endpoint' => ['required', 'max:255', new SafeWebhookUrl],
'usePathStyleEndpoint' => 'boolean',
];
}
@@ -82,6 +87,7 @@ class Form extends Component
'secret' => 'Secret',
'bucket' => 'Bucket',
'endpoint' => 'Endpoint',
'usePathStyleEndpoint' => 'Use path-style URLs',
];
/**
@@ -98,6 +104,7 @@ class Form extends Component
$this->storage->endpoint = $this->endpoint;
$this->storage->bucket = $this->bucket;
$this->storage->region = $this->region;
$this->storage->use_path_style_endpoint = $this->usePathStyleEndpoint;
$this->storage->key = $this->key;
$this->storage->secret = $this->secret;
$this->storage->is_usable = $this->isUsable;
@@ -110,6 +117,9 @@ class Form extends Component
$this->endpointPartsChanged = false;
$this->bucket = $this->storage->bucket;
$this->region = $this->storage->region;
$this->usePathStyleEndpoint = $this->storage->usesPathStyleEndpoint();
// A saved style that detection would not pick is a user choice, so keep it when the endpoint changes.
$this->usePathStyleEndpointChanged = $this->usePathStyleEndpoint !== S3Storage::detectPathStyleEndpoint($this->endpoint);
$canSeeCredentials = auth()->user()?->can('update', $this->storage) ?? false;
$this->key = $canSeeCredentials ? $this->storage->key : '';
$this->secret = $canSeeCredentials ? $this->storage->secret : '';
@@ -142,10 +152,13 @@ class Form extends Component
$testedStorage->endpoint = $this->endpoint;
$testedStorage->bucket = $this->bucket;
$testedStorage->region = $this->region;
$testedStorage->use_path_style_endpoint = $this->usePathStyleEndpoint;
$testedStorage->key = $this->key;
$testedStorage->secret = $this->secret;
$testedStorage->testConnection();
$switchedToVirtualHostedStyle = $this->usePathStyleEndpoint && ! $testedStorage->usesPathStyleEndpoint();
$this->usePathStyleEndpoint = $testedStorage->usesPathStyleEndpoint();
// Update component property to reflect the new validation status
$this->isUsable = $testedStorage->is_usable;
@@ -154,7 +167,9 @@ class Form extends Component
$this->storage->save();
$this->dispatch('storage-status-changed', isUsable: $this->isUsable);
return $this->dispatch('success', 'Connection is working.', 'Tested with "ListObjectsV2" action.');
return $this->dispatch('success', 'Connection is working.', $switchedToVirtualHostedStyle
? 'The provider rejects path-style URLs, so "Use path-style URLs" is now off. Save to keep it.'
: 'Tested with "ListObjectsV2" action.');
} catch (\Throwable $e) {
if ($testedStorage) {
$this->isUsable = $testedStorage->is_usable;
@@ -192,9 +207,11 @@ class Form extends Component
$this->storage->unusable_email_sent = false;
$this->storage->save();
// Update local property to reflect success
// Update local properties to reflect success
$this->isUsable = true;
$this->usePathStyleEndpoint = $this->storage->usesPathStyleEndpoint();
});
$this->dispatch('name-changed', uuid: $this->storage->uuid, name: $this->storage->name);
$this->dispatch('success', 'Storage settings updated and connection verified.');
} catch (\Throwable $e) {
@@ -209,5 +226,13 @@ class Form extends Component
public function updatedEndpointParts(): void
{
$this->endpointPartsChanged = true;
if (! $this->usePathStyleEndpointChanged) {
$this->usePathStyleEndpoint = S3Storage::detectPathStyleEndpoint(DomainUrlParts::compose(...$this->endpointParts));
}
}
public function updatedUsePathStyleEndpoint(): void
{
$this->usePathStyleEndpointChanged = true;
}
}
+4 -1
View File
@@ -9,7 +9,10 @@ class SwitchTeam extends Component
{
public string $selectedTeamId = 'default';
protected $listeners = ['team-avatar-updated' => '$refresh'];
protected $listeners = [
'team-avatar-updated' => '$refresh',
'team-updated' => '$refresh',
];
public function mount()
{
+1
View File
@@ -147,6 +147,7 @@ class Index extends Component
$this->syncData(true);
$this->team->save();
refreshSession();
$this->dispatch('team-updated');
$this->dispatch('success', 'Team updated.');
} catch (\Throwable $e) {
return handleError($e, $this);
+16 -19
View File
@@ -25,12 +25,7 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::ADMIN->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::ADMIN);
$this->updateRole(Role::ADMIN);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -46,12 +41,7 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::OWNER->value]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::OWNER);
$this->updateRole(Role::OWNER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -67,12 +57,7 @@ class Member extends Component
|| Role::from($this->getMemberRole())->gt(auth()->user()->role())) {
throw new \Exception('You are not authorized to perform this action.');
}
$teamId = currentTeam()->id;
DB::transaction(function () use ($teamId): void {
$this->member->teams()->updateExistingPivot($teamId, ['role' => Role::MEMBER->value, 'access' => null]);
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
});
$this->auditRoleUpdate($teamId, Role::MEMBER);
$this->updateRole(Role::MEMBER);
$this->dispatch('reloadWindow');
} catch (\Exception $e) {
$this->dispatch('error', $e->getMessage());
@@ -114,8 +99,20 @@ class Member extends Component
return $this->member->teams()->where('teams.id', currentTeam()->id)->first()?->pivot?->role;
}
private function auditRoleUpdate(int $teamId, Role $role): void
private function updateRole(Role $role): void
{
$teamId = currentTeam()->id;
$previousRole = Role::from($this->getMemberRole());
DB::transaction(function () use ($teamId, $role, $previousRole): void {
// A member gets the default team-wide access again.
$this->member->teams()->updateExistingPivot($teamId, $role === Role::MEMBER
? ['role' => $role->value, 'access' => null]
: ['role' => $role->value]);
// A promotion keeps the tokens: the API checks the abilities against the role on each request.
if ($role->lt($previousRole)) {
RevokeUserTeamTokens::forUserTeam($this->member, $teamId);
}
});
auditLog('ui.team_member.role_updated', [
'team_id' => $teamId,
'member_id' => $this->member->id,
+43 -4
View File
@@ -1586,6 +1586,8 @@ class Application extends BaseModel
: 'GIT_SSH_COMMAND="'.$resolvedGitSshCommand.'"')
: 'GIT_SSH_COMMAND="ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null"';
$git_clone_command = $this->gitLfsSkipSmudgePrefix($onlyCheckout).$git_clone_command;
// Use the explicitly passed commit (e.g. from rollback), falling back to the application's git_commit_sha.
// Invalid refs will cause the git checkout/fetch command to fail on the remote server.
$commitToUse = $commit ?? $this->git_commit_sha;
@@ -1593,9 +1595,18 @@ class Application extends BaseModel
if ($commitToUse !== 'HEAD') {
$escapedCommit = escapeshellarg($commitToUse);
// If shallow clone is enabled and we need a specific commit,
// we need to fetch that specific commit with depth=1
// we need to fetch that specific commit with depth=1.
// A remote cannot fetch a short SHA (Bitbucket sends one for pull requests),
// so fetch the tip of each branch and let the checkout expand it. When the
// commit is not a branch tip, fetch the full history of the branches.
if ($isShallowCloneEnabled) {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && echo 'Fetching the commit.' && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
$checkoutCommand = "{$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
if (preg_match('/\A[0-9a-fA-F]{7,39}\z/', $commitToUse) === 1) {
$branchesRefspec = escapeshellarg('+refs/heads/*:refs/remotes/origin/*');
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && echo 'Fetching the commit.' && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$branchesRefspec} && ({$checkoutCommand} || (echo 'The commit is not a branch tip. Fetching the full history.' && {$sshCommand} {$gitCommand} fetch --unshallow origin {$branchesRefspec} && {$checkoutCommand}))";
} else {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && echo 'Fetching the commit.' && {$sshCommand} {$gitCommand} fetch --depth=1 origin {$escapedCommit} && {$checkoutCommand}";
}
} else {
$git_clone_command = "{$git_clone_command} && cd {$escapedBaseDir} && {$sshCommand} {$gitCommand} -c advice.detachedHead=false checkout {$escapedCommit} >/dev/null 2>&1";
}
@@ -1866,6 +1877,16 @@ class Application extends BaseModel
];
}
/**
* The helper image installs the Git LFS filter system-wide, so every checkout downloads
* LFS objects unless the smudge filter is skipped. A checkout that only reads files (the
* Compose file) never needs LFS objects.
*/
private function gitLfsSkipSmudgePrefix(bool $onlyCheckout = false): string
{
return $this->settings->is_git_lfs_enabled && ! $onlyCheckout ? '' : 'export GIT_LFS_SKIP_SMUDGE=1 && ';
}
private function gitCommandsAsShellCommand(Collection|array|string $commands): string
{
if (is_string($commands)) {
@@ -1979,10 +2000,11 @@ class Application extends BaseModel
$git_checkout_command = $this->buildGitCheckoutCommand($pr_branch_name, gitConfigOptions: $gitConfigOption ?? null);
$escapedPrBranch = escapeshellarg($branch);
$git_pr_command = "{$this->gitLfsSkipSmudgePrefix($only_checkout)}cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command";
if ($exec_in_docker) {
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, "cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command")));
$commands->push($this->gitCommand(executeInDocker($deployment_uuid, $git_pr_command)));
} else {
$commands->push($this->gitCommand("cd {$escapedBaseDir} && git fetch origin {$escapedPrBranch} && $git_checkout_command"));
$commands->push($this->gitCommand($git_pr_command));
}
}
@@ -2266,6 +2288,23 @@ class Application extends BaseModel
instant_remote_process($commands, $this->destination->server, false);
}
/**
* The services of the Compose file, read without parse(). A parse saves volumes and variables, so
* code that only needs the service names uses this.
*
* @return array<string, mixed>
*/
public function composeServicesWithoutParse(): array
{
try {
$services = data_get(parseDockerComposeYaml((string) $this->docker_compose_raw), 'services');
} catch (\Throwable) {
return [];
}
return is_array($services) ? $services : [];
}
public function parse(int $pull_request_id = 0, ?int $preview_id = null, ?string $commit = null)
{
if ((int) $this->compose_parsing_version >= 3) {
+8
View File
@@ -42,6 +42,13 @@ class EnvironmentVariable extends BaseModel
{
use Auditable;
/**
* Flags that a "# coolify:" line of the developer view can set, with their defaults.
*
* @var array<string, bool>
*/
public const DEV_VIEW_FLAG_DEFAULTS = ['literal' => false, 'buildtime' => true, 'runtime' => true];
public const BUILDPACK_CONTROL_VARIABLE_PREFIXES = ['NIXPACKS_', 'RAILPACK_'];
/**
@@ -101,6 +108,7 @@ class EnvironmentVariable extends BaseModel
'value' => 'encrypted',
'preserve_compose_null' => 'boolean',
'is_multiline' => 'boolean',
'is_literal' => 'boolean',
'is_preview' => 'boolean',
'is_runtime' => 'boolean',
'is_buildtime' => 'boolean',
+44 -18
View File
@@ -72,6 +72,11 @@ class LocalFileVolume extends BaseModel
done
SH;
/**
* The pull request whose preview this copy writes. Not saved: see forPullRequest().
*/
protected int $previewPullRequestId = 0;
protected $casts = [
// 'fs_path' => 'encrypted',
// 'mount_path' => 'encrypted',
@@ -343,10 +348,17 @@ class LocalFileVolume extends BaseModel
validateShellSafePath($path, 'storage path');
$escapedPath = escapeshellarg($path);
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
$state = self::remoteFileStates([(string) $path], $server)[0];
if ($state === 'unknown') {
throw new \Exception("Coolify cannot check the file {$path} on the server.");
}
if ($state === 'other' && ! $this->is_directory) {
throw new \Exception("A symbolic link or a special file is at {$path} on the server. Coolify did not write the file.");
}
$isFile = $state === 'file';
$isDir = in_array($state, ['directory', 'empty-directory'], true);
$replacesEmptyDirectory = false;
if ($isFile === 'OK' && $this->is_directory) {
if ($isFile && $this->is_directory) {
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
$this->content = self::TOO_LARGE_PLACEHOLDER;
} else {
@@ -356,7 +368,7 @@ class LocalFileVolume extends BaseModel
$this->save();
FileStorageChanged::dispatch(data_get($server, 'team_id'));
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
} elseif ($isDir === 'OK' && ! $this->is_directory) {
} elseif ($isDir && ! $this->is_directory) {
if ($path === '/' || $path === '.' || $path === '..' || $path === '' || str($path)->isEmpty() || is_null($path)) {
$this->is_directory = true;
$this->save();
@@ -364,12 +376,12 @@ class LocalFileVolume extends BaseModel
}
// Docker creates a missing bind source as an empty directory. Replace only an empty
// directory inside the resource directory; never delete files that are on the server.
if ($isOutsideResourceDirectory || self::remoteFileStates([(string) $path], $server)[0] !== 'empty-directory') {
if ($isOutsideResourceDirectory || $state !== 'empty-directory') {
throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}<br><br>Please delete the directory on the server or mark it as directory.");
}
$replacesEmptyDirectory = true;
}
if (($isDir === 'NOK' || $replacesEmptyDirectory) && ! $this->is_directory) {
if ((! $isDir || $replacesEmptyDirectory) && ! $this->is_directory) {
if ($replacesEmptyDirectory) {
$commands->push("rmdir {$escapedPath}");
}
@@ -388,7 +400,7 @@ class LocalFileVolume extends BaseModel
if ($chmod) {
$commands->push('chmod -- '.escapeshellarg($chmod)." {$escapedPath}");
}
} elseif ($isDir === 'NOK' && $this->is_directory) {
} elseif (! $isDir && $this->is_directory) {
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
}
@@ -405,22 +417,24 @@ class LocalFileVolume extends BaseModel
* front of it and never changes the script. See REMOTE_FILE_STATE_SCRIPT for the output.
*
* @param list<string> $paths
* @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg().
*/
public static function remoteFileStateCommand(array $paths): string
public static function remoteFileStateCommand(array $paths, ?Closure $quote = null): string
{
return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map('escapeshellarg', $paths));
return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map($quote ?? escapeshellarg(...), $paths));
}
/**
* Gets the state of each path with one server command. A path without a state in the output is `unknown`.
*
* @param list<string> $paths
* @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg().
* @return list<string>
*/
public static function remoteFileStates(array $paths, Server $server): array
public static function remoteFileStates(array $paths, Server $server, ?Closure $quote = null): array
{
$states = [];
$output = (string) instant_remote_process([self::remoteFileStateCommand($paths)], $server, false);
$output = (string) instant_remote_process([self::remoteFileStateCommand($paths, $quote)], $server, false);
foreach (preg_split('/\R/', trim($output)) as $line) {
if (preg_match('/^(\d+):([a-z-]+)$/', trim($line), $matches)) {
$states[(int) $matches[1] - 1] = $matches[2];
@@ -560,16 +574,28 @@ class LocalFileVolume extends BaseModel
public function resolvedFsPath(string $workdir): Stringable
{
$path = trim((string) $this->fs_path);
if (str_starts_with($path, '/') || str_starts_with($path, '~')) {
return str($path);
}
if (str_starts_with($path, '.')) {
$path = substr($path, 1);
if (! str_starts_with($path, '/') && ! str_starts_with($path, '~')) {
if (str_starts_with($path, '.')) {
$path = substr($path, 1);
}
$path = ltrim($path, '/');
$path = rtrim($workdir, '/').($path === '' ? '' : '/'.$path);
}
$path = ltrim($path, '/');
return str(addPreviewDeploymentSuffix($path, $this->previewPullRequestId));
}
return str(rtrim($workdir, '/').($path === '' ? '' : '/'.$path));
/**
* A copy that uses the path of the preview of a pull request, as the Compose parser mounts it
* (`<path>-pr-<id>` unless the PR suffix is off for this storage). The row keeps the path of
* the application, so saving the copy never stores the preview path.
*/
public function forPullRequest(int $pullRequestId): static
{
$copy = clone $this;
$copy->previewPullRequestId = $pullRequestId !== 0 && ($this->is_preview_suffix_enabled ?? true) ? $pullRequestId : 0;
return $copy;
}
/**
+79 -4
View File
@@ -23,6 +23,17 @@ class S3Storage extends BaseModel
private const REQUEST_TIMEOUT_SECONDS = 15;
/**
* Endpoint host suffixes of providers that reject path-style URLs: Alibaba Cloud OSS, Tencent Cloud COS
* (buckets created after 2024-01-01), and Volcengine / BytePlus TOS.
*/
private const VIRTUAL_HOSTED_STYLE_HOST_SUFFIXES = ['aliyuncs.com', 'myqcloud.com', 'volces.com', 'bytepluses.com'];
/**
* Parts of the errors that these providers return for a path-style request.
*/
private const PATH_STYLE_REJECTION_ERRORS = ['secondleveldomainforbidden', 'pathstyledomainforbidden', 'virtual hosted style', 'virtual-hosted style', 'virtual-styled'];
protected $fillable = [
'team_id',
'name',
@@ -32,6 +43,7 @@ class S3Storage extends BaseModel
'secret',
'bucket',
'endpoint',
'use_path_style_endpoint',
'is_usable',
'unusable_email_sent',
];
@@ -43,6 +55,7 @@ class S3Storage extends BaseModel
protected $casts = [
'is_usable' => 'boolean',
'use_path_style_endpoint' => 'boolean',
'key' => 'encrypted',
'secret' => 'encrypted',
];
@@ -217,9 +230,7 @@ class S3Storage extends BaseModel
throw new \RuntimeException('S3 bucket name is not allowed: '.$validator->errors()->first('bucket'));
}
$disk = $this->filesystem();
// Test the connection by listing files with ListObjectsV2 (S3)
$disk->files();
$this->listFilesWithAddressingStyleFallback();
$this->unusable_email_sent = false;
$this->is_usable = true;
@@ -265,7 +276,7 @@ class S3Storage extends BaseModel
'secret' => $this['secret'],
'bucket' => $this['bucket'],
'endpoint' => $this['endpoint'],
'use_path_style_endpoint' => true,
'use_path_style_endpoint' => $this->usesPathStyleEndpoint(),
'http' => array_merge(SafeWebhookUrl::httpClientOptions($this['endpoint'], $this->trustedInternalHosts()), [
'connect_timeout' => self::CONNECTION_TIMEOUT_SECONDS,
'timeout' => self::REQUEST_TIMEOUT_SECONDS,
@@ -282,6 +293,70 @@ class S3Storage extends BaseModel
return '-e '.escapeshellarg('MC_REGION='.$this->region);
}
/**
* Path-style URLs (`endpoint/bucket`) are the default. Providers like Alibaba Cloud OSS only accept
* virtual-hosted-style URLs (`bucket.endpoint`). A storage that is not saved yet has no value, so it uses the default.
*/
public function usesPathStyleEndpoint(): bool
{
return $this->use_path_style_endpoint ?? true;
}
/**
* The URL style for a new storage: virtual-hosted style for providers that reject path-style URLs, else path-style.
*/
public static function detectPathStyleEndpoint(?string $endpoint): bool
{
$host = strtolower((string) parse_url((string) $endpoint, PHP_URL_HOST));
return ! collect(self::VIRTUAL_HOSTED_STYLE_HOST_SUFFIXES)
->contains(fn (string $suffix): bool => $host === $suffix || str_ends_with($host, '.'.$suffix));
}
/**
* Tests the connection with ListObjectsV2. When the provider rejects path-style URLs, it tests again with
* virtual-hosted-style URLs and keeps that style when it works.
*/
private function listFilesWithAddressingStyleFallback(): void
{
try {
$this->filesystem()->files();
} catch (\Throwable $exception) {
if (! $this->usesPathStyleEndpoint() || ! self::isPathStyleRejection($exception)) {
throw $exception;
}
$this->use_path_style_endpoint = false;
try {
$this->filesystem()->files();
} catch (\Throwable) {
$this->use_path_style_endpoint = true;
throw $exception;
}
}
}
private static function isPathStyleRejection(\Throwable $exception): bool
{
for ($current = $exception; $current !== null; $current = $current->getPrevious()) {
if (str($current->getMessage())->lower()->contains(self::PATH_STYLE_REJECTION_ERRORS)) {
return true;
}
}
return false;
}
/**
* The `mc alias set` option that makes the MinIO client use virtual-hosted-style URLs, as filesystem() does.
* With path-style URLs, mc keeps its own `auto` lookup, as before.
*/
public function minioClientPathOption(): string
{
return $this->usesPathStyleEndpoint() ? '' : ' --path off';
}
/**
* The bundled MinIO container is a trusted internal S3 target, not a user-supplied webhook destination.
*
+15 -1
View File
@@ -462,6 +462,20 @@ class Server extends BaseModel
static::flushIdentityMap();
}
/**
* Refresh the status of the linked cloud server from its provider.
*/
public function refreshCloudProviderState(): ?string
{
return match ($this->cloudProviderToken?->provider) {
'hetzner' => $this->hetzner_server_id ? $this->refreshHetznerState() : null,
'vultr' => $this->vultr_instance_id ? $this->refreshVultrState() : null,
'digitalocean' => $this->digitalocean_droplet_id ? $this->refreshDigitalOceanState() : null,
'hostinger' => $this->hostinger_virtual_machine_id ? $this->refreshHostingerState() : null,
default => null,
};
}
public function refreshHetznerState(): ?string
{
// Another instance manages the cloud server of an archive; keep the stored status.
@@ -2200,7 +2214,7 @@ $siteAddress {
*/
public function isFluentBitLogDrainEnabled(): bool
{
return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_highlight_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled;
return $this->settings->is_logdrain_newrelic_enabled || $this->settings->is_logdrain_axiom_enabled || $this->settings->is_logdrain_custom_enabled;
}
public function validateOS(): bool|Stringable
+7 -4
View File
@@ -29,7 +29,6 @@ use OpenApi\Attributes as OA;
'is_logdrain_axiom_enabled' => ['type' => 'boolean'],
'is_logdrain_cloudwatch_enabled' => ['type' => 'boolean'],
'is_logdrain_custom_enabled' => ['type' => 'boolean'],
'is_logdrain_highlight_enabled' => ['type' => 'boolean'],
'is_logdrain_newrelic_enabled' => ['type' => 'boolean'],
'is_metrics_enabled' => ['type' => 'boolean'],
'is_traffic_analytics_enabled' => ['type' => 'boolean'],
@@ -49,12 +48,12 @@ use OpenApi\Attributes as OA;
'is_usable' => ['type' => 'boolean'],
'logdrain_axiom_api_key' => ['type' => 'string'],
'logdrain_axiom_dataset_name' => ['type' => 'string'],
'logdrain_axiom_domain' => ['type' => 'string', 'description' => 'Base domain of the Axiom edge deployment, for example us-east-1.aws.edge.axiom.co or eu-central-1.aws.edge.axiom.co.'],
'logdrain_cloudwatch_region' => ['type' => 'string'],
'logdrain_cloudwatch_group' => ['type' => 'string'],
'logdrain_cloudwatch_stream_prefix' => ['type' => 'string'],
'logdrain_custom_config' => ['type' => 'string'],
'logdrain_custom_config_parser' => ['type' => 'string'],
'logdrain_highlight_project_id' => ['type' => 'string'],
'logdrain_newrelic_base_uri' => ['type' => 'string'],
'logdrain_newrelic_license_key' => ['type' => 'string'],
'sentinel_metrics_history_days' => ['type' => 'integer'],
@@ -93,6 +92,11 @@ class ServerSetting extends Model
public const int DEFAULT_SENTINEL_PUSH_INTERVAL_SECONDS = 60;
/**
* Axiom edge deployment for US East 1. See https://axiom.co/docs/reference/edge-deployments.
*/
public const string DEFAULT_AXIOM_DOMAIN = 'us-east-1.aws.edge.axiom.co';
protected $fillable = [
'server_id',
'is_swarm_manager',
@@ -109,11 +113,10 @@ class ServerSetting extends Model
'is_logdrain_newrelic_enabled',
'logdrain_newrelic_license_key',
'logdrain_newrelic_base_uri',
'is_logdrain_highlight_enabled',
'logdrain_highlight_project_id',
'is_logdrain_axiom_enabled',
'logdrain_axiom_dataset_name',
'logdrain_axiom_api_key',
'logdrain_axiom_domain',
'is_swarm_worker',
'is_logdrain_custom_enabled',
'logdrain_custom_config',
+9
View File
@@ -11,6 +11,14 @@ class SharedEnvironmentVariable extends Model
{
use Auditable;
/**
* Flags that a "# coolify:" line of the developer view can set, with their defaults.
* Shared variables have no buildtime or runtime flags.
*
* @var array<string, bool>
*/
public const DEV_VIEW_FLAG_DEFAULTS = ['literal' => false];
protected $fillable = [
// Core identification
'key',
@@ -40,6 +48,7 @@ class SharedEnvironmentVariable extends Model
protected $casts = [
'key' => 'string',
'value' => 'encrypted',
'is_literal' => 'boolean',
];
/**
+1 -1
View File
@@ -56,7 +56,7 @@ class StandaloneKeydb extends BaseModel
'health_check_start_period',
];
protected $appends = ['internal_db_url', 'external_db_url', 'server_status'];
protected $appends = ['internal_db_url', 'external_db_url', 'database_type', 'server_status'];
/**
* Sensitive fields hidden by default in serialized output (toArray/toJson).
-1
View File
@@ -174,7 +174,6 @@ class User extends Authenticatable implements PasskeyUser, SendsEmail
if ($found_other_member_who_is_not_owner) {
$found_other_member_who_is_not_owner->pivot->role = 'owner';
$found_other_member_who_is_not_owner->pivot->save();
RevokeUserTeamTokens::forUserTeam($found_other_member_who_is_not_owner, $team->id);
$team->members()->detach($user->id);
} else {
static::finalizeTeamDeletion($user, $team);
+8
View File
@@ -10,6 +10,7 @@ use App\Models\Server;
use App\Rules\SafeExternalUrl;
use App\Services\Access\AccessResolver;
use App\Support\AtomicFilesystem;
use App\Support\RetryHttpConnectionFailures;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Foundation\Console\ConfigCacheCommand;
use Illuminate\Foundation\Console\RouteCacheCommand;
@@ -45,6 +46,7 @@ class AppServiceProvider extends ServiceProvider
$this->configureModels();
$this->configurePasswords();
$this->configureSanctumModel();
$this->configureHttpClient();
$this->configureGitHubHttp();
$this->configureGitLabHttp();
$this->configureOidcSocialite();
@@ -126,6 +128,12 @@ class AppServiceProvider extends ServiceProvider
});
}
private function configureHttpClient(): void
{
Http::globalOptions(['connect_timeout' => 10, 'timeout' => 30]);
Http::globalMiddleware(new RetryHttpConnectionFailures);
}
private function configureGitHubHttp(): void
{
Http::macro('GitSource', function (string $url) {
+10 -5
View File
@@ -9,6 +9,11 @@ use Illuminate\Translation\PotentiallyTranslatedString;
class DockerImageFormat implements ValidationRule
{
/**
* @param bool $allowVariableTag Allow $VAR tags. Use it only for images that Docker Compose runs, because only Compose fills in the variables.
*/
public function __construct(private bool $allowVariableTag = false) {}
/**
* Run the validation rule.
*
@@ -35,15 +40,15 @@ class DockerImageFormat implements ValidationRule
if (preg_match('/\A(.+)@sha256:([a-f0-9]{64})\z/i', $value, $matches) === 1) {
$imageName = $matches[1];
} else {
$lastColon = strrpos($value, ':');
$lastSlash = strrpos($value, '/');
if ($lastColon !== false && ($lastSlash === false || $lastColon > $lastSlash)) {
$imageName = substr($value, 0, $lastColon);
$tag = substr($value, $lastColon + 1);
$tagColon = strpos($value, ':', $lastSlash === false ? 0 : $lastSlash + 1);
if ($tagColon !== false) {
$imageName = substr($value, 0, $tagColon);
$tag = substr($value, $tagColon + 1);
}
}
if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag)) {
if (! ValidationPatterns::isValidDockerImageName($imageName) || ! ValidationPatterns::isValidDockerImageTag($tag, allowVariables: $this->allowVariableTag)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
}
}
+1 -10
View File
@@ -2,14 +2,13 @@
namespace App\Services;
use Illuminate\Http\Client\PendingRequest;
use Illuminate\Support\Facades\Http;
class CloudflareTokenValidator
{
public function validate(string $token, array $capabilities): bool
{
$client = $this->client($token);
$client = Http::withToken($token)->acceptJson();
$verification = $client->get('https://api.cloudflare.com/client/v4/user/tokens/verify');
if (! $verification->successful() || $verification->json('result.status') !== 'active') {
@@ -42,12 +41,4 @@ class CloudflareTokenValidator
return true;
}
private function client(string $token): PendingRequest
{
return Http::withToken($token)
->acceptJson()
->connectTimeout(5)
->timeout(10);
}
}

Some files were not shown because too many files have changed in this diff Show More