coolify/app/Models/Service.php
Andras Bacsai ac9eca3c05 fix: don't show health status for exited containers
Exited containers don't run health checks, so showing "(unhealthy)" is
misleading. This fix ensures exited status displays without health
suffixes across all monitoring systems (SSH, Sentinel, services, etc.)
and at the UI layer for backward compatibility with existing data.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-24 09:09:37 +01:00

1428 lines
63 KiB
PHP

<?php
namespace App\Models;
use App\Enums\ProcessStatus;
use App\Services\ContainerStatusAggregator;
use App\Traits\ClearsGlobalSearchCache;
use App\Traits\HasSafeStringAttribute;
use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Collection;
use Illuminate\Support\Facades\Storage;
use OpenApi\Attributes as OA;
use Spatie\Activitylog\Models\Activity;
use Spatie\Url\Url;
use Visus\Cuid2\Cuid2;
#[OA\Schema(
description: 'Service model',
type: 'object',
properties: [
'id' => ['type' => 'integer', 'description' => 'The unique identifier of the service. Only used for database identification.'],
'uuid' => ['type' => 'string', 'description' => 'The unique identifier of the service.'],
'name' => ['type' => 'string', 'description' => 'The name of the service.'],
'environment_id' => ['type' => 'integer', 'description' => 'The unique identifier of the environment where the service is attached to.'],
'server_id' => ['type' => 'integer', 'description' => 'The unique identifier of the server where the service is running.'],
'description' => ['type' => 'string', 'description' => 'The description of the service.'],
'docker_compose_raw' => ['type' => 'string', 'description' => 'The raw docker-compose.yml file of the service.'],
'docker_compose' => ['type' => 'string', 'description' => 'The docker-compose.yml file that is parsed and modified by Coolify.'],
'destination_type' => ['type' => 'string', 'description' => 'Destination type.'],
'destination_id' => ['type' => 'integer', 'description' => 'The unique identifier of the destination where the service is running.'],
'connect_to_docker_network' => ['type' => 'boolean', 'description' => 'The flag to connect the service to the predefined Docker network.'],
'is_container_label_escape_enabled' => ['type' => 'boolean', 'description' => 'The flag to enable the container label escape.'],
'is_container_label_readonly_enabled' => ['type' => 'boolean', 'description' => 'The flag to enable the container label readonly.'],
'config_hash' => ['type' => 'string', 'description' => 'The hash of the service configuration.'],
'service_type' => ['type' => 'string', 'description' => 'The type of the service.'],
'created_at' => ['type' => 'string', 'description' => 'The date and time when the service was created.'],
'updated_at' => ['type' => 'string', 'description' => 'The date and time when the service was last updated.'],
'deleted_at' => ['type' => 'string', 'description' => 'The date and time when the service was deleted.'],
],
)]
class Service extends BaseModel
{
use ClearsGlobalSearchCache, HasFactory, HasSafeStringAttribute, SoftDeletes;
private static $parserVersion = '5';
protected $guarded = [];
protected $appends = ['server_status', 'status'];
protected static function booted()
{
static::creating(function ($service) {
if (blank($service->name)) {
$service->name = 'service-'.(new Cuid2);
}
});
static::created(function ($service) {
$service->compose_parsing_version = self::$parserVersion;
$service->save();
});
}
public function isConfigurationChanged(bool $save = false)
{
$domains = $this->applications()->get()->pluck('fqdn')->sort()->toArray();
$domains = implode(',', $domains);
$applicationImages = $this->applications()->get()->pluck('image')->sort();
$databaseImages = $this->databases()->get()->pluck('image')->sort();
$images = $applicationImages->merge($databaseImages);
$images = implode(',', $images->toArray());
$applicationStorages = $this->applications()->get()->pluck('persistentStorages')->flatten()->sortBy('id');
$databaseStorages = $this->databases()->get()->pluck('persistentStorages')->flatten()->sortBy('id');
$storages = $applicationStorages->merge($databaseStorages)->implode('updated_at');
$newConfigHash = $images.$domains.$images.$storages;
$newConfigHash .= json_encode($this->environment_variables()->get('value')->sort());
$newConfigHash = md5($newConfigHash);
$oldConfigHash = data_get($this, 'config_hash');
if ($oldConfigHash === null) {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
if ($oldConfigHash === $newConfigHash) {
return false;
} else {
if ($save) {
$this->config_hash = $newConfigHash;
$this->save();
}
return true;
}
}
protected function serverStatus(): Attribute
{
return Attribute::make(
get: function () {
return $this->server->isFunctional();
}
);
}
public function isRunning()
{
return (bool) str($this->status)->contains('running');
}
public function isExited()
{
return (bool) str($this->status)->contains('exited');
}
public function isStarting(): bool
{
try {
$activity = Activity::where('properties->type_uuid', $this->uuid)->latest()->first();
$status = data_get($activity, 'properties.status');
return $status === ProcessStatus::QUEUED->value || $status === ProcessStatus::IN_PROGRESS->value;
} catch (\Throwable) {
return false;
}
}
public function type()
{
return 'service';
}
public function project()
{
return data_get($this, 'environment.project');
}
public function team()
{
return data_get($this, 'environment.project.team');
}
public function tags()
{
return $this->morphToMany(Tag::class, 'taggable');
}
public static function ownedByCurrentTeam()
{
return Service::whereRelation('environment.project.team', 'id', currentTeam()->id)->orderBy('name');
}
public function deleteConfigurations()
{
$server = data_get($this, 'destination.server');
$workdir = $this->workdir();
if (str($workdir)->endsWith($this->uuid)) {
instant_remote_process(['rm -rf '.$this->workdir()], $server, false);
}
}
public function deleteConnectedNetworks()
{
$server = data_get($this, 'destination.server');
instant_remote_process(["docker network disconnect {$this->uuid} coolify-proxy"], $server, false);
instant_remote_process(["docker network rm {$this->uuid}"], $server, false);
}
/**
* Calculate the service's aggregate status from its applications and databases.
*
* This method aggregates status from Eloquent model relationships (not Docker containers).
* It differs from the CalculatesExcludedStatus trait which works with Docker container objects
* during container inspection. This accessor runs on-demand for UI display and works with
* already-stored status strings from the database.
*
* Status format: "{status}:{health}" or "{status}:{health}:excluded"
* - Status values: running, exited, degraded, starting, paused, restarting
* - Health values: healthy, unhealthy, unknown
* - :excluded suffix: Indicates all containers are excluded from health monitoring
*
* @return string The aggregate status in format "status:health" or "status:health:excluded"
*/
public function getStatusAttribute()
{
if ($this->isStarting()) {
return 'starting:unhealthy';
}
$applications = $this->applications;
$databases = $this->databases;
[$complexStatus, $complexHealth, $hasNonExcluded] = $this->aggregateResourceStatuses(
$applications,
$databases,
excludedOnly: false
);
// If all services are excluded from status checks, calculate status from excluded containers
// but mark it with :excluded to indicate monitoring is disabled
if (! $hasNonExcluded && ($complexStatus === null && $complexHealth === null)) {
[$excludedStatus, $excludedHealth] = $this->aggregateResourceStatuses(
$applications,
$databases,
excludedOnly: true
);
// Return status with :excluded suffix to indicate monitoring is disabled
if ($excludedStatus && $excludedHealth) {
return "{$excludedStatus}:{$excludedHealth}:excluded";
}
// If no status was calculated at all (no containers exist), return unknown
if ($excludedStatus === null && $excludedHealth === null) {
return 'unknown:unknown:excluded';
}
return 'exited';
}
// If health is null/empty, return just the status without trailing colon
if ($complexHealth === null || $complexHealth === '') {
return $complexStatus;
}
return "{$complexStatus}:{$complexHealth}";
}
/**
* Aggregate status and health from collections of applications and databases.
*
* This helper method consolidates status aggregation logic using ContainerStatusAggregator.
* It processes container status strings stored in the database (not live Docker data).
*
* @param \Illuminate\Database\Eloquent\Collection $applications Collection of Application models
* @param \Illuminate\Database\Eloquent\Collection $databases Collection of Database models
* @param bool $excludedOnly If true, only process excluded containers; if false, only process non-excluded
* @return array{0: string|null, 1: string|null, 2?: bool} [status, health, hasNonExcluded (only when excludedOnly=false)]
*/
private function aggregateResourceStatuses($applications, $databases, bool $excludedOnly = false): array
{
$hasNonExcluded = false;
$statusStrings = collect();
// Process both applications and databases using the same logic
$resources = $applications->concat($databases);
foreach ($resources as $resource) {
$isExcluded = $resource->exclude_from_status || str($resource->status)->contains(':excluded');
// Filter based on excludedOnly flag
if ($excludedOnly && ! $isExcluded) {
continue;
}
if (! $excludedOnly && $isExcluded) {
continue;
}
if (! $excludedOnly) {
$hasNonExcluded = true;
}
// Strip :excluded suffix before aggregation (it's in the 3rd part of "status:health:excluded")
$status = str($resource->status)->before(':excluded')->toString();
$statusStrings->push($status);
}
// If no status strings collected, return nulls
if ($statusStrings->isEmpty()) {
return $excludedOnly ? [null, null] : [null, null, $hasNonExcluded];
}
// Use ContainerStatusAggregator service for state machine logic
$aggregator = new ContainerStatusAggregator;
$aggregatedStatus = $aggregator->aggregateFromStrings($statusStrings);
// Parse the aggregated "status:health" string
$parts = explode(':', $aggregatedStatus);
$status = $parts[0] ?? null;
$health = $parts[1] ?? null;
if ($excludedOnly) {
return [$status, $health];
}
return [$status, $health, $hasNonExcluded];
}
public function extraFields()
{
$fields = collect([]);
$applications = $this->applications()->get();
foreach ($applications as $application) {
$image = str($application->image)->before(':');
if ($image->isEmpty()) {
continue;
}
switch ($image) {
case $image->contains('drizzle-team/gateway'):
$data = collect([]);
$masterpass = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_DRIZZLE')->first();
$data = $data->merge([
'Master Password' => [
'key' => data_get($masterpass, 'key'),
'value' => data_get($masterpass, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
$fields->put('Drizzle', $data->toArray());
break;
case $image->contains('castopod'):
$data = collect([]);
$disable_https = $this->environment_variables()->where('key', 'CP_DISABLE_HTTPS')->first();
if ($disable_https) {
$data = $data->merge([
'Disable HTTPS' => [
'key' => 'CP_DISABLE_HTTPS',
'value' => data_get($disable_https, 'value'),
'rules' => 'required',
'customHelper' => 'If you want to use https, set this to 0. Variable name: CP_DISABLE_HTTPS',
],
]);
}
$fields->put('Castopod', $data->toArray());
break;
case $image->contains('label-studio'):
$data = collect([]);
$username = $this->environment_variables()->where('key', 'LABEL_STUDIO_USERNAME')->first();
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_LABELSTUDIO')->first();
if ($username) {
$data = $data->merge([
'Username' => [
'key' => 'LABEL_STUDIO_USERNAME',
'value' => data_get($username, 'value'),
'rules' => 'required',
],
]);
}
if ($password) {
$data = $data->merge([
'Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Label Studio', $data->toArray());
break;
case $image->contains('litellm'):
$data = collect([]);
$username = $this->environment_variables()->where('key', 'SERVICE_USER_UI')->first();
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_UI')->first();
if ($username) {
$data = $data->merge([
'Username' => [
'key' => data_get($username, 'key'),
'value' => data_get($username, 'value'),
'rules' => 'required',
],
]);
}
if ($password) {
$data = $data->merge([
'Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Litellm', $data->toArray());
break;
case $image->contains('langfuse'):
$data = collect([]);
$email = $this->environment_variables()->where('key', 'LANGFUSE_INIT_USER_EMAIL')->first();
if ($email) {
$data = $data->merge([
'Admin Email' => [
'key' => data_get($email, 'key'),
'value' => data_get($email, 'value'),
'rules' => 'required|email',
],
]);
}
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_LANGFUSE')->first();
if ($password) {
$data = $data->merge([
'Admin Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Langfuse', $data->toArray());
break;
case $image->contains('invoiceninja'):
$data = collect([]);
$email = $this->environment_variables()->where('key', 'IN_USER_EMAIL')->first();
$data = $data->merge([
'Email' => [
'key' => data_get($email, 'key'),
'value' => data_get($email, 'value'),
'rules' => 'required|email',
],
]);
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_INVOICENINJAUSER')->first();
$data = $data->merge([
'Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
$fields->put('Invoice Ninja', $data->toArray());
break;
case $image->contains('argilla'):
$data = collect([]);
$api_key = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_APIKEY')->first();
$data = $data->merge([
'API Key' => [
'key' => data_get($api_key, 'key'),
'value' => data_get($api_key, 'value'),
'isPassword' => true,
'rules' => 'required',
],
]);
$data = $data->merge([
'API Key' => [
'key' => data_get($api_key, 'key'),
'value' => data_get($api_key, 'value'),
'isPassword' => true,
'rules' => 'required',
],
]);
$username = $this->environment_variables()->where('key', 'ARGILLA_USERNAME')->first();
$data = $data->merge([
'Username' => [
'key' => data_get($username, 'key'),
'value' => data_get($username, 'value'),
'rules' => 'required',
],
]);
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ARGILLA')->first();
$data = $data->merge([
'Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
$fields->put('Argilla', $data->toArray());
break;
case $image->contains('rabbitmq'):
$data = collect([]);
$host_port = $this->environment_variables()->where('key', 'PORT')->first();
$username = $this->environment_variables()->where('key', 'SERVICE_USER_RABBITMQ')->first();
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_RABBITMQ')->first();
if ($host_port) {
$data = $data->merge([
'Host Port Binding' => [
'key' => data_get($host_port, 'key'),
'value' => data_get($host_port, 'value'),
'rules' => 'required',
],
]);
}
if ($username) {
$data = $data->merge([
'Username' => [
'key' => data_get($username, 'key'),
'value' => data_get($username, 'value'),
'rules' => 'required',
],
]);
}
if ($password) {
$data = $data->merge([
'Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('RabbitMQ', $data->toArray());
break;
case $image->contains('tolgee'):
$data = collect([]);
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_TOLGEE')->first();
$data = $data->merge([
'Admin User' => [
'key' => 'TOLGEE_AUTHENTICATION_INITIAL_USERNAME',
'value' => 'admin',
'readonly' => true,
'rules' => 'required',
],
]);
if ($admin_password) {
$data = $data->merge([
'Admin Password' => [
'key' => data_get($admin_password, 'key'),
'value' => data_get($admin_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Tolgee', $data->toArray());
break;
case $image->contains('logto'):
$data = collect([]);
$logto_endpoint = $this->environment_variables()->where('key', 'LOGTO_ENDPOINT')->first();
$logto_admin_endpoint = $this->environment_variables()->where('key', 'LOGTO_ADMIN_ENDPOINT')->first();
if ($logto_endpoint) {
$data = $data->merge([
'Endpoint' => [
'key' => data_get($logto_endpoint, 'key'),
'value' => data_get($logto_endpoint, 'value'),
'rules' => 'required|url',
],
]);
}
if ($logto_admin_endpoint) {
$data = $data->merge([
'Admin Endpoint' => [
'key' => data_get($logto_admin_endpoint, 'key'),
'value' => data_get($logto_admin_endpoint, 'value'),
'rules' => 'required|url',
],
]);
}
$fields->put('Logto', $data->toArray());
break;
case $image->contains('unleash-server'):
$data = collect([]);
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_UNLEASH')->first();
$data = $data->merge([
'Admin User' => [
'key' => 'SERVICE_USER_UNLEASH',
'value' => 'admin',
'readonly' => true,
'rules' => 'required',
],
]);
if ($admin_password) {
$data = $data->merge([
'Admin Password' => [
'key' => data_get($admin_password, 'key'),
'value' => data_get($admin_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Unleash', $data->toArray());
break;
case $image->contains('grafana'):
$data = collect([]);
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GRAFANA')->first();
$data = $data->merge([
'Admin User' => [
'key' => 'GF_SECURITY_ADMIN_USER',
'value' => 'admin',
'readonly' => true,
'rules' => 'required',
],
]);
if ($admin_password) {
$data = $data->merge([
'Admin Password' => [
'key' => data_get($admin_password, 'key'),
'value' => data_get($admin_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Grafana', $data->toArray());
break;
case $image->contains('elasticsearch'):
$data = collect([]);
$elastic_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ELASTICSEARCH')->first();
if ($elastic_password) {
$data = $data->merge([
'Password (default user: elastic)' => [
'key' => data_get($elastic_password, 'key'),
'value' => data_get($elastic_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Elasticsearch', $data->toArray());
break;
case $image->contains('directus'):
$data = collect([]);
$admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first();
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ADMIN')->first();
if ($admin_email) {
$data = $data->merge([
'Admin Email' => [
'key' => data_get($admin_email, 'key'),
'value' => data_get($admin_email, 'value'),
'rules' => 'required|email',
],
]);
}
if ($admin_password) {
$data = $data->merge([
'Admin Password' => [
'key' => data_get($admin_password, 'key'),
'value' => data_get($admin_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Directus', $data->toArray());
break;
case $image->contains('kong'):
$data = collect([]);
$dashboard_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first();
$dashboard_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ADMIN')->first();
if ($dashboard_user) {
$data = $data->merge([
'Dashboard User' => [
'key' => data_get($dashboard_user, 'key'),
'value' => data_get($dashboard_user, 'value'),
'rules' => 'required',
],
]);
}
if ($dashboard_password) {
$data = $data->merge([
'Dashboard Password' => [
'key' => data_get($dashboard_password, 'key'),
'value' => data_get($dashboard_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Supabase', $data->toArray());
case $image->contains('minio'):
$data = collect([]);
$console_url = $this->environment_variables()->where('key', 'MINIO_BROWSER_REDIRECT_URL')->first();
$s3_api_url = $this->environment_variables()->where('key', 'MINIO_SERVER_URL')->first();
$admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_MINIO')->first();
if (is_null($admin_user)) {
$admin_user = $this->environment_variables()->where('key', 'MINIO_ROOT_USER')->first();
}
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_MINIO')->first();
if (is_null($admin_password)) {
$admin_password = $this->environment_variables()->where('key', 'MINIO_ROOT_PASSWORD')->first();
}
if ($console_url) {
$data = $data->merge([
'Console URL' => [
'key' => data_get($console_url, 'key'),
'value' => data_get($console_url, 'value'),
'rules' => 'required|url',
],
]);
}
if ($s3_api_url) {
$data = $data->merge([
'S3 API URL' => [
'key' => data_get($s3_api_url, 'key'),
'value' => data_get($s3_api_url, 'value'),
'rules' => 'required|url',
],
]);
}
if ($admin_user) {
$data = $data->merge([
'Admin User' => [
'key' => data_get($admin_user, 'key'),
'value' => data_get($admin_user, 'value'),
'rules' => 'required',
],
]);
}
if ($admin_password) {
$data = $data->merge([
'Admin Password' => [
'key' => data_get($admin_password, 'key'),
'value' => data_get($admin_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('MinIO', $data->toArray());
break;
case $image->contains('weblate'):
$data = collect([]);
$admin_email = $this->environment_variables()->where('key', 'WEBLATE_ADMIN_EMAIL')->first();
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_WEBLATE')->first();
if ($admin_email) {
$data = $data->merge([
'Admin Email' => [
'key' => data_get($admin_email, 'key'),
'value' => data_get($admin_email, 'value'),
'rules' => 'required|email',
],
]);
}
if ($admin_password) {
$data = $data->merge([
'Admin Password' => [
'key' => data_get($admin_password, 'key'),
'value' => data_get($admin_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Weblate', $data->toArray());
break;
case $image->contains('meilisearch'):
$data = collect([]);
$SERVICE_PASSWORD_MEILISEARCH = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_MEILISEARCH')->first();
if ($SERVICE_PASSWORD_MEILISEARCH) {
$data = $data->merge([
'API Key' => [
'key' => data_get($SERVICE_PASSWORD_MEILISEARCH, 'key'),
'value' => data_get($SERVICE_PASSWORD_MEILISEARCH, 'value'),
'isPassword' => true,
],
]);
}
$fields->put('Meilisearch', $data->toArray());
break;
case $image->contains('ghost'):
$data = collect([]);
$MAIL_OPTIONS_AUTH_PASS = $this->environment_variables()->where('key', 'MAIL_OPTIONS_AUTH_PASS')->first();
$MAIL_OPTIONS_AUTH_USER = $this->environment_variables()->where('key', 'MAIL_OPTIONS_AUTH_USER')->first();
$MAIL_OPTIONS_SECURE = $this->environment_variables()->where('key', 'MAIL_OPTIONS_SECURE')->first();
$MAIL_OPTIONS_PORT = $this->environment_variables()->where('key', 'MAIL_OPTIONS_PORT')->first();
$MAIL_OPTIONS_SERVICE = $this->environment_variables()->where('key', 'MAIL_OPTIONS_SERVICE')->first();
$MAIL_OPTIONS_HOST = $this->environment_variables()->where('key', 'MAIL_OPTIONS_HOST')->first();
if ($MAIL_OPTIONS_AUTH_PASS) {
$data = $data->merge([
'Mail Password' => [
'key' => data_get($MAIL_OPTIONS_AUTH_PASS, 'key'),
'value' => data_get($MAIL_OPTIONS_AUTH_PASS, 'value'),
'isPassword' => true,
],
]);
}
if ($MAIL_OPTIONS_AUTH_USER) {
$data = $data->merge([
'Mail User' => [
'key' => data_get($MAIL_OPTIONS_AUTH_USER, 'key'),
'value' => data_get($MAIL_OPTIONS_AUTH_USER, 'value'),
],
]);
}
if ($MAIL_OPTIONS_SECURE) {
$data = $data->merge([
'Mail Secure' => [
'key' => data_get($MAIL_OPTIONS_SECURE, 'key'),
'value' => data_get($MAIL_OPTIONS_SECURE, 'value'),
],
]);
}
if ($MAIL_OPTIONS_PORT) {
$data = $data->merge([
'Mail Port' => [
'key' => data_get($MAIL_OPTIONS_PORT, 'key'),
'value' => data_get($MAIL_OPTIONS_PORT, 'value'),
],
]);
}
if ($MAIL_OPTIONS_SERVICE) {
$data = $data->merge([
'Mail Service' => [
'key' => data_get($MAIL_OPTIONS_SERVICE, 'key'),
'value' => data_get($MAIL_OPTIONS_SERVICE, 'value'),
],
]);
}
if ($MAIL_OPTIONS_HOST) {
$data = $data->merge([
'Mail Host' => [
'key' => data_get($MAIL_OPTIONS_HOST, 'key'),
'value' => data_get($MAIL_OPTIONS_HOST, 'value'),
],
]);
}
$fields->put('Ghost', $data->toArray());
break;
case $image->contains('vaultwarden'):
$data = collect([]);
$DATABASE_URL = $this->environment_variables()->where('key', 'DATABASE_URL')->first();
$ADMIN_TOKEN = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_64_ADMIN')->first();
$SIGNUP_ALLOWED = $this->environment_variables()->where('key', 'SIGNUP_ALLOWED')->first();
$PUSH_ENABLED = $this->environment_variables()->where('key', 'PUSH_ENABLED')->first();
$PUSH_INSTALLATION_ID = $this->environment_variables()->where('key', 'PUSH_SERVICE_ID')->first();
$PUSH_INSTALLATION_KEY = $this->environment_variables()->where('key', 'PUSH_SERVICE_KEY')->first();
if ($DATABASE_URL) {
$data = $data->merge([
'Database URL' => [
'key' => data_get($DATABASE_URL, 'key'),
'value' => data_get($DATABASE_URL, 'value'),
],
]);
}
if ($ADMIN_TOKEN) {
$data = $data->merge([
'Admin Password' => [
'key' => data_get($ADMIN_TOKEN, 'key'),
'value' => data_get($ADMIN_TOKEN, 'value'),
'isPassword' => true,
],
]);
}
if ($SIGNUP_ALLOWED) {
$data = $data->merge([
'Signup Allowed' => [
'key' => data_get($SIGNUP_ALLOWED, 'key'),
'value' => data_get($SIGNUP_ALLOWED, 'value'),
'rules' => 'required|string|in:true,false',
],
]);
}
if ($PUSH_ENABLED) {
$data = $data->merge([
'Push Enabled' => [
'key' => data_get($PUSH_ENABLED, 'key'),
'value' => data_get($PUSH_ENABLED, 'value'),
'rules' => 'required|string|in:true,false',
],
]);
}
if ($PUSH_INSTALLATION_ID) {
$data = $data->merge([
'Push Installation ID' => [
'key' => data_get($PUSH_INSTALLATION_ID, 'key'),
'value' => data_get($PUSH_INSTALLATION_ID, 'value'),
],
]);
}
if ($PUSH_INSTALLATION_KEY) {
$data = $data->merge([
'Push Installation Key' => [
'key' => data_get($PUSH_INSTALLATION_KEY, 'key'),
'value' => data_get($PUSH_INSTALLATION_KEY, 'value'),
'isPassword' => true,
],
]);
}
$fields->put('Vaultwarden', $data);
break;
case $image->contains('gitlab/gitlab'):
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_GITLAB')->first();
$data = collect([]);
if ($password) {
$data = $data->merge([
'Root Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$data = $data->merge([
'Root User' => [
'key' => 'GITLAB_ROOT_USER',
'value' => 'root',
'rules' => 'required',
'isPassword' => true,
],
]);
$fields->put('GitLab', $data->toArray());
break;
case $image->contains('code-server'):
$data = collect([]);
$password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_64_PASSWORDCODESERVER')->first();
if ($password) {
$data = $data->merge([
'Password' => [
'key' => data_get($password, 'key'),
'value' => data_get($password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$sudoPassword = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_SUDOCODESERVER')->first();
if ($sudoPassword) {
$data = $data->merge([
'Sudo Password' => [
'key' => data_get($sudoPassword, 'key'),
'value' => data_get($sudoPassword, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
$fields->put('Code Server', $data->toArray());
break;
case $image->contains('elestio/strapi'):
$data = collect([]);
$license = $this->environment_variables()->where('key', 'STRAPI_LICENSE')->first();
if ($license) {
$data = $data->merge([
'License' => [
'key' => data_get($license, 'key'),
'value' => data_get($license, 'value'),
],
]);
}
$nodeEnv = $this->environment_variables()->where('key', 'NODE_ENV')->first();
if ($nodeEnv) {
$data = $data->merge([
'Node Environment' => [
'key' => data_get($nodeEnv, 'key'),
'value' => data_get($nodeEnv, 'value'),
],
]);
}
$fields->put('Strapi', $data->toArray());
break;
default:
$data = collect([]);
$admin_user = $this->environment_variables()->where('key', 'SERVICE_USER_ADMIN')->first();
// Chaskiq
$admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first();
$admin_password = $this->environment_variables()->where('key', 'SERVICE_PASSWORD_ADMIN')->first();
if ($admin_user) {
$data = $data->merge([
'User' => [
'key' => data_get($admin_user, 'key'),
'value' => data_get($admin_user, 'value', 'admin'),
'readonly' => true,
'rules' => 'required',
],
]);
}
if ($admin_password) {
$data = $data->merge([
'Password' => [
'key' => data_get($admin_password, 'key'),
'value' => data_get($admin_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
if ($admin_email) {
$data = $data->merge([
'Email' => [
'key' => data_get($admin_email, 'key'),
'value' => data_get($admin_email, 'value'),
'rules' => 'required|email',
],
]);
}
$fields->put('Admin', $data->toArray());
break;
}
}
$databases = $this->databases()->get();
foreach ($databases as $database) {
$image = str($database->image)->before(':');
if ($image->isEmpty()) {
continue;
}
switch ($image) {
case $image->contains('postgres'):
$userVariables = ['SERVICE_USER_POSTGRES', 'SERVICE_USER_POSTGRESQL'];
$passwordVariables = ['SERVICE_PASSWORD_POSTGRES', 'SERVICE_PASSWORD_POSTGRESQL'];
$dbNameVariables = ['POSTGRESQL_DATABASE', 'POSTGRES_DB'];
$postgres_user = $this->environment_variables()->whereIn('key', $userVariables)->first();
$postgres_password = $this->environment_variables()->whereIn('key', $passwordVariables)->first();
$postgres_db_name = $this->environment_variables()->whereIn('key', $dbNameVariables)->first();
$data = collect([]);
if ($postgres_user) {
$data = $data->merge([
'User' => [
'key' => data_get($postgres_user, 'key'),
'value' => data_get($postgres_user, 'value'),
'rules' => 'required',
],
]);
}
if ($postgres_password) {
$data = $data->merge([
'Password' => [
'key' => data_get($postgres_password, 'key'),
'value' => data_get($postgres_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
if ($postgres_db_name) {
$data = $data->merge([
'Database Name' => [
'key' => data_get($postgres_db_name, 'key'),
'value' => data_get($postgres_db_name, 'value'),
'rules' => 'required',
],
]);
}
$fields->put('PostgreSQL', $data->toArray());
break;
case $image->contains('mysql'):
$userVariables = ['SERVICE_USER_MYSQL', 'SERVICE_USER_WORDPRESS', 'MYSQL_USER'];
$passwordVariables = ['SERVICE_PASSWORD_MYSQL', 'SERVICE_PASSWORD_WORDPRESS', 'MYSQL_PASSWORD', 'SERVICE_PASSWORD_64_MYSQL'];
$rootPasswordVariables = ['SERVICE_PASSWORD_MYSQLROOT', 'SERVICE_PASSWORD_ROOT', 'SERVICE_PASSWORD_64_MYSQLROOT'];
$dbNameVariables = ['MYSQL_DATABASE'];
$mysql_user = $this->environment_variables()->whereIn('key', $userVariables)->first();
$mysql_password = $this->environment_variables()->whereIn('key', $passwordVariables)->first();
$mysql_root_password = $this->environment_variables()->whereIn('key', $rootPasswordVariables)->first();
$mysql_db_name = $this->environment_variables()->whereIn('key', $dbNameVariables)->first();
$data = collect([]);
if ($mysql_user) {
$data = $data->merge([
'User' => [
'key' => data_get($mysql_user, 'key'),
'value' => data_get($mysql_user, 'value'),
'rules' => 'required',
],
]);
}
if ($mysql_password) {
$data = $data->merge([
'Password' => [
'key' => data_get($mysql_password, 'key'),
'value' => data_get($mysql_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
if ($mysql_root_password) {
$data = $data->merge([
'Root Password' => [
'key' => data_get($mysql_root_password, 'key'),
'value' => data_get($mysql_root_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
if ($mysql_db_name) {
$data = $data->merge([
'Database Name' => [
'key' => data_get($mysql_db_name, 'key'),
'value' => data_get($mysql_db_name, 'value'),
'rules' => 'required',
],
]);
}
$fields->put('MySQL', $data->toArray());
break;
case $image->contains('mariadb'):
$userVariables = ['SERVICE_USER_MARIADB', 'SERVICE_USER_WORDPRESS', 'SERVICE_USER_MYSQL', 'MYSQL_USER'];
$passwordVariables = ['SERVICE_PASSWORD_MARIADB', 'SERVICE_PASSWORD_WORDPRESS', '_APP_DB_PASS', 'MYSQL_PASSWORD'];
$rootPasswordVariables = ['SERVICE_PASSWORD_MARIADBROOT', 'SERVICE_PASSWORD_ROOT', '_APP_DB_ROOT_PASS', 'MYSQL_ROOT_PASSWORD'];
$dbNameVariables = ['SERVICE_DATABASE_MARIADB', 'SERVICE_DATABASE_WORDPRESS', '_APP_DB_SCHEMA', 'MYSQL_DATABASE'];
$mariadb_user = $this->environment_variables()->whereIn('key', $userVariables)->first();
$mariadb_password = $this->environment_variables()->whereIn('key', $passwordVariables)->first();
$mariadb_root_password = $this->environment_variables()->whereIn('key', $rootPasswordVariables)->first();
$mariadb_db_name = $this->environment_variables()->whereIn('key', $dbNameVariables)->first();
$data = collect([]);
if ($mariadb_user) {
$data = $data->merge([
'User' => [
'key' => data_get($mariadb_user, 'key'),
'value' => data_get($mariadb_user, 'value'),
'rules' => 'required',
],
]);
}
if ($mariadb_password) {
$data = $data->merge([
'Password' => [
'key' => data_get($mariadb_password, 'key'),
'value' => data_get($mariadb_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
if ($mariadb_root_password) {
$data = $data->merge([
'Root Password' => [
'key' => data_get($mariadb_root_password, 'key'),
'value' => data_get($mariadb_root_password, 'value'),
'rules' => 'required',
'isPassword' => true,
],
]);
}
if ($mariadb_db_name) {
$data = $data->merge([
'Database Name' => [
'key' => data_get($mariadb_db_name, 'key'),
'value' => data_get($mariadb_db_name, 'value'),
'rules' => 'required',
],
]);
}
$fields->put('MariaDB', $data->toArray());
break;
}
}
$fields = collect($fields)->map(function ($extraFields) {
if (is_array($extraFields)) {
$extraFields = collect($extraFields)->map(function ($field) {
if (filled($field['value']) && str($field['value'])->startsWith('$SERVICE_')) {
$searchValue = str($field['value'])->after('$')->value;
$newValue = $this->environment_variables()->where('key', $searchValue)->first();
if ($newValue) {
$field['value'] = $newValue->value;
}
}
return $field;
});
}
return $extraFields;
});
return $fields;
}
public function saveExtraFields($fields)
{
foreach ($fields as $field) {
$key = data_get($field, 'key');
$value = data_get($field, 'value');
$found = $this->environment_variables()->where('key', $key)->first();
if ($found) {
$found->value = $value;
$found->save();
} else {
$this->environment_variables()->create([
'key' => $key,
'value' => $value,
'resourceable_id' => $this->id,
'resourceable_type' => $this->getMorphClass(),
'is_preview' => false,
]);
}
}
}
public function link()
{
if (data_get($this, 'environment.project.uuid')) {
return route('project.service.configuration', [
'project_uuid' => data_get($this, 'environment.project.uuid'),
'environment_uuid' => data_get($this, 'environment.uuid'),
'service_uuid' => data_get($this, 'uuid'),
]);
}
return null;
}
public function taskLink($task_uuid)
{
if (data_get($this, 'environment.project.uuid')) {
$route = route('project.service.scheduled-tasks', [
'project_uuid' => data_get($this, 'environment.project.uuid'),
'environment_uuid' => data_get($this, 'environment.uuid'),
'service_uuid' => data_get($this, 'uuid'),
'task_uuid' => $task_uuid,
]);
$settings = InstanceSettings::get();
if (data_get($settings, 'fqdn')) {
$url = Url::fromString($route);
$url = $url->withPort(null);
$fqdn = data_get($settings, 'fqdn');
$fqdn = str_replace(['http://', 'https://'], '', $fqdn);
$url = $url->withHost($fqdn);
return $url->__toString();
}
return $route;
}
return null;
}
public function documentation()
{
$services = get_service_templates();
$service = data_get($services, str($this->name)->beforeLast('-')->value, []);
return data_get($service, 'documentation', config('constants.urls.docs'));
}
/**
* Get the required port for this service from the template definition.
*/
public function getRequiredPort(): ?int
{
try {
$services = get_service_templates();
$serviceName = str($this->name)->beforeLast('-')->value();
$service = data_get($services, $serviceName, []);
$port = data_get($service, 'port');
return $port ? (int) $port : null;
} catch (\Throwable) {
return null;
}
}
/**
* Check if this service requires a port to function correctly.
*/
public function requiresPort(): bool
{
return $this->getRequiredPort() !== null;
}
public function applications()
{
return $this->hasMany(ServiceApplication::class);
}
public function databases()
{
return $this->hasMany(ServiceDatabase::class);
}
public function destination()
{
return $this->morphTo();
}
public function environment()
{
return $this->belongsTo(Environment::class);
}
public function server()
{
return $this->belongsTo(Server::class);
}
public function byUuid(string $uuid)
{
$app = $this->applications()->whereUuid($uuid)->first();
if ($app) {
return $app;
}
$db = $this->databases()->whereUuid($uuid)->first();
if ($db) {
return $db;
}
return null;
}
public function byName(string $name)
{
$app = $this->applications()->whereName($name)->first();
if ($app) {
return $app;
}
$db = $this->databases()->whereName($name)->first();
if ($db) {
return $db;
}
return null;
}
public function scheduled_tasks(): HasMany
{
return $this->hasMany(ScheduledTask::class)->orderBy('name', 'asc');
}
public function environment_variables()
{
return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw("
CASE
WHEN is_required = true THEN 1
WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3
END,
LOWER(key) ASC
");
}
public function workdir()
{
return service_configuration_dir()."/{$this->uuid}";
}
public function saveComposeConfigs()
{
// Guard against null or empty docker_compose
if (! $this->docker_compose) {
return;
}
$workdir = $this->workdir();
instant_remote_process([
"mkdir -p $workdir",
"cd $workdir",
], $this->server);
$filename = new Cuid2.'-docker-compose.yml';
Storage::disk('local')->put("tmp/{$filename}", $this->docker_compose);
$path = Storage::path("tmp/{$filename}");
instant_scp($path, "{$workdir}/docker-compose.yml", $this->server);
Storage::disk('local')->delete("tmp/{$filename}");
$commands[] = "cd $workdir";
$commands[] = 'rm -f .env || true';
$envs = collect([]);
// Generate SERVICE_NAME_* environment variables from docker-compose services
if ($this->docker_compose) {
try {
$dockerCompose = \Symfony\Component\Yaml\Yaml::parse($this->docker_compose);
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->replace('-', '_')->replace('.', '_')->upper().'='.$serviceName);
}
} catch (\Exception $e) {
ray($e->getMessage());
}
}
$envs_from_coolify = $this->environment_variables()->get();
$sorted = $envs_from_coolify->sortBy(function ($env) {
if (str($env->key)->startsWith('SERVICE_')) {
return 1;
}
if (str($env->value)->startsWith('$SERVICE_') || str($env->value)->startsWith('${SERVICE_')) {
return 2;
}
return 3;
});
foreach ($sorted as $env) {
$envs->push("{$env->key}={$env->real_value}");
}
if ($envs->count() === 0) {
$commands[] = 'touch .env';
} else {
$envs_base64 = base64_encode($envs->implode("\n"));
$commands[] = "echo '$envs_base64' | base64 -d | tee .env > /dev/null";
}
instant_remote_process($commands, $this->server);
}
public function parse(bool $isNew = false): Collection
{
if ((int) $this->compose_parsing_version >= 3) {
return serviceParser($this);
} elseif ($this->docker_compose_raw) {
return parseDockerComposeFile($this, $isNew);
} else {
return collect([]);
}
}
public function networks()
{
return getTopLevelNetworks($this);
}
protected function isDeployable(): Attribute
{
return Attribute::make(
get: function () {
$envs = $this->environment_variables()->where('is_required', true)->get();
foreach ($envs as $env) {
if ($env->is_really_required) {
return false;
}
}
return true;
}
);
}
}