Merge branch 'next' into next

This commit is contained in:
Seefs 2025-10-06 08:06:46 +08:00 committed by GitHub
commit bac6f4df81
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1612 additions and 417 deletions

View file

@ -2,7 +2,78 @@ # Changelog
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## [unreleased] ## [4.0.0-beta.434] - 2025-10-03
### 🚀 Features
- *(deployments)* Enhance Docker build argument handling for multiline variables
- *(deployments)* Add log copying functionality to clipboard in dev
- *(deployments)* Generate SERVICE_NAME environment variables from Docker Compose services
### 🐛 Bug Fixes
- *(deployments)* Enhance builder container management and environment variable handling
### 📚 Documentation
- Update changelog
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(versions)* Update version numbers for Coolify releases
- *(versions)* Bump Coolify stable version to 4.0.0-beta.434
## [4.0.0-beta.433] - 2025-10-01
### 🚀 Features
- *(user-deletion)* Implement file locking to prevent concurrent user deletions and enhance error handling
- *(ui)* Enhance resource operations interface with dynamic selection for cloning and moving resources
- *(global-search)* Integrate projects and environments into global search functionality
- *(storage)* Consolidate storage management into a single component with enhanced UI
- *(deployments)* Add support for Coolify variables in Dockerfile
### 🐛 Bug Fixes
- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow
- *(ui)* Update docker registry image helper text for clarity
- *(ui)* Correct HTML structure and improve clarity in Docker cleanup options
- *(workflows)* Update CLAUDE API key reference in GitHub Actions workflow
- *(api)* Correct OpenAPI schema annotations for array items
- *(ui)* Improve queued deployment status readability in dark mode
- *(git)* Handle additional repository URL cases for 'tangled' and improve branch assignment logic
- *(git)* Enhance error handling for missing branch information during deployment
- *(git)* Trim whitespace from repository, branch, and commit SHA fields
- *(deployments)* Order deployments by ID for consistent retrieval
### 💼 Other
- *(storage)* Enhance file storage management with new properties and UI improvements
- *(core)* Update projects property type and enhance UI styling
- *(components)* Adjust SVG icon sizes for consistency across applications and services
- *(components)* Auto-focus first input in modal on open
- *(styles)* Enhance focus styles for buttons and links
- *(components)* Enhance close button accessibility in modal
### 🚜 Refactor
- *(global-search)* Change event listener to window level for global search modal
- *(dashboard)* Remove deployment loading logic and introduce DeploymentsIndicator component for better UI management
- *(dashboard)* Replace project navigation method with direct link in UI
- *(global-search)* Improve event handling and cleanup in global search component
### 📚 Documentation
- Update changelog
- Update changelog
- Update changelog
### ⚙️ Miscellaneous Tasks
- *(versions)* Update coolify version to 4.0.0-beta.433 and nightly version to 4.0.0-beta.434 in configuration files
## [4.0.0-beta.432] - 2025-09-29
### 🚀 Features ### 🚀 Features
@ -188,6 +259,7 @@ ## [4.0.0-beta.427] - 2025-09-15
### 🚀 Features ### 🚀 Features
- Add Ente Photos service template
- *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic - *(command)* Add option to sync GitHub releases to BunnyCDN and refactor sync logic
- *(ui)* Display current version in settings dropdown and update UI accordingly - *(ui)* Display current version in settings dropdown and update UI accordingly
- *(settings)* Add option to restrict PR deployments to repository members and contributors - *(settings)* Add option to restrict PR deployments to repository members and contributors

View file

@ -1512,9 +1512,32 @@ private function create_application(Request $request, $type)
if ($return instanceof \Illuminate\Http\JsonResponse) { if ($return instanceof \Illuminate\Http\JsonResponse) {
return $return; return $return;
} }
if (! $request->docker_registry_image_tag) { // Process docker image name and tag for SHA256 digests
$request->offsetSet('docker_registry_image_tag', 'latest'); $dockerImageName = $request->docker_registry_image_name;
$dockerImageTag = $request->docker_registry_image_tag;
// Strip 'sha256:' prefix if user provided it in the tag
if ($dockerImageTag) {
$dockerImageTag = preg_replace('/^sha256:/i', '', trim($dockerImageTag));
} }
// Remove @sha256 from image name if user added it
if ($dockerImageName) {
$dockerImageName = preg_replace('/@sha256$/i', '', trim($dockerImageName));
}
// Check if tag is a valid SHA256 hash (64 hex characters)
$isSha256Hash = $dockerImageTag && preg_match('/^[a-f0-9]{64}$/i', $dockerImageTag);
// Append @sha256 to image name if using digest and not already present
if ($isSha256Hash && ! str_ends_with($dockerImageName, '@sha256')) {
$dockerImageName .= '@sha256';
}
// Set processed values back to request
$request->offsetSet('docker_registry_image_name', $dockerImageName);
$request->offsetSet('docker_registry_image_tag', $dockerImageTag ?: 'latest');
$application = new Application; $application = new Application;
removeUnnecessaryFieldsFromRequest($request); removeUnnecessaryFieldsFromRequest($request);

View file

@ -116,16 +116,12 @@ class ApplicationDeploymentJob implements ShouldBeEncrypted, ShouldQueue
private $env_args; private $env_args;
private $environment_variables;
private $env_nixpacks_args; private $env_nixpacks_args;
private $docker_compose; private $docker_compose;
private $docker_compose_base64; private $docker_compose_base64;
private ?string $env_filename = null;
private ?string $nixpacks_plan = null; private ?string $nixpacks_plan = null;
private Collection $nixpacks_plan_json; private Collection $nixpacks_plan_json;
@ -503,7 +499,12 @@ private function deploy_dockerimage_buildpack()
} else { } else {
$this->dockerImageTag = $this->application->docker_registry_image_tag; $this->dockerImageTag = $this->application->docker_registry_image_tag;
} }
$this->application_deployment_queue->addLogEntry("Starting deployment of {$this->dockerImage}:{$this->dockerImageTag} to {$this->server->name}.");
// Check if this is an image hash deployment
$isImageHash = str($this->dockerImageTag)->startsWith('sha256-');
$displayName = $isImageHash ? "{$this->dockerImage}@sha256:".str($this->dockerImageTag)->after('sha256-') : "{$this->dockerImage}:{$this->dockerImageTag}";
$this->application_deployment_queue->addLogEntry("Starting deployment of {$displayName} to {$this->server->name}.");
$this->generate_image_names(); $this->generate_image_names();
$this->prepare_builder_image(); $this->prepare_builder_image();
$this->generate_compose_file(); $this->generate_compose_file();
@ -571,7 +572,6 @@ private function deploy_docker_compose_buildpack()
if ($this->application->settings->is_raw_compose_deployment_enabled) { if ($this->application->settings->is_raw_compose_deployment_enabled) {
$this->application->oldRawParser(); $this->application->oldRawParser();
$yaml = $composeFile = $this->application->docker_compose_raw; $yaml = $composeFile = $this->application->docker_compose_raw;
$this->generate_runtime_environment_variables();
// For raw compose, we cannot automatically add secrets configuration // For raw compose, we cannot automatically add secrets configuration
// User must define it manually in their docker-compose file // User must define it manually in their docker-compose file
@ -580,16 +580,14 @@ private function deploy_docker_compose_buildpack()
} }
} else { } else {
$composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id')); $composeFile = $this->application->parse(pull_request_id: $this->pull_request_id, preview_id: data_get($this->preview, 'id'));
$this->generate_runtime_environment_variables(); // Always add .env file to services
if (filled($this->env_filename)) { $services = collect(data_get($composeFile, 'services', []));
$services = collect(data_get($composeFile, 'services', [])); $services = $services->map(function ($service, $name) {
$services = $services->map(function ($service, $name) { $service['env_file'] = ['.env'];
$service['env_file'] = [$this->env_filename];
return $service; return $service;
}); });
$composeFile['services'] = $services->toArray(); $composeFile['services'] = $services->toArray();
}
if (empty($composeFile)) { if (empty($composeFile)) {
$this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.'); $this->application_deployment_queue->addLogEntry('Failed to parse docker-compose file.');
$this->fail('Failed to parse docker-compose file.'); $this->fail('Failed to parse docker-compose file.');
@ -615,6 +613,9 @@ private function deploy_docker_compose_buildpack()
// Build new container to limit downtime. // Build new container to limit downtime.
$this->application_deployment_queue->addLogEntry('Pulling & building required images.'); $this->application_deployment_queue->addLogEntry('Pulling & building required images.');
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
if ($this->docker_compose_custom_build_command) { if ($this->docker_compose_custom_build_command) {
// Prepend DOCKER_BUILDKIT=1 if BuildKit is supported // Prepend DOCKER_BUILDKIT=1 if BuildKit is supported
$build_command = $this->docker_compose_custom_build_command; $build_command = $this->docker_compose_custom_build_command;
@ -630,9 +631,8 @@ private function deploy_docker_compose_buildpack()
if ($this->dockerBuildkitSupported) { if ($this->dockerBuildkitSupported) {
$command = "DOCKER_BUILDKIT=1 {$command}"; $command = "DOCKER_BUILDKIT=1 {$command}";
} }
if (filled($this->env_filename)) { // Always use .env file
$command .= " --env-file {$this->workdir}/{$this->env_filename}"; $command .= " --env-file {$this->workdir}/.env";
}
if ($this->force_rebuild) { if ($this->force_rebuild) {
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} build --pull --no-cache";
} else { } else {
@ -652,6 +652,10 @@ private function deploy_docker_compose_buildpack()
); );
} }
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->stop_running_container(force: true); $this->stop_running_container(force: true);
$this->application_deployment_queue->addLogEntry('Starting new application.'); $this->application_deployment_queue->addLogEntry('Starting new application.');
$networkId = $this->application->uuid; $networkId = $this->application->uuid;
@ -685,9 +689,8 @@ private function deploy_docker_compose_buildpack()
$this->docker_compose_location = '/docker-compose.yaml'; $this->docker_compose_location = '/docker-compose.yaml';
$command = "{$this->coolify_variables} docker compose"; $command = "{$this->coolify_variables} docker compose";
if (filled($this->env_filename)) { // Always use .env file
$command .= " --env-file {$server_workdir}/{$this->env_filename}"; $command .= " --env-file {$server_workdir}/.env";
}
$command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $command .= " --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command( $this->execute_remote_command(
['command' => $command, 'hidden' => true], ['command' => $command, 'hidden' => true],
@ -702,9 +705,8 @@ private function deploy_docker_compose_buildpack()
} else { } else {
$command = "{$this->coolify_variables} docker compose"; $command = "{$this->coolify_variables} docker compose";
if ($this->preserveRepository) { if ($this->preserveRepository) {
if (filled($this->env_filename)) { // Always use .env file
$command .= " --env-file {$server_workdir}/{$this->env_filename}"; $command .= " --env-file {$server_workdir}/.env";
}
$command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d"; $command .= " --project-name {$this->application->uuid} --project-directory {$server_workdir} -f {$server_workdir}{$this->docker_compose_location} up -d";
$this->write_deployment_configurations(); $this->write_deployment_configurations();
@ -712,9 +714,8 @@ private function deploy_docker_compose_buildpack()
['command' => $command, 'hidden' => true], ['command' => $command, 'hidden' => true],
); );
} else { } else {
if (filled($this->env_filename)) { // Always use .env file
$command .= " --env-file {$this->workdir}/{$this->env_filename}"; $command .= " --env-file {$this->workdir}/.env";
}
$command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d"; $command .= " --project-name {$this->application->uuid} --project-directory {$this->workdir} -f {$this->workdir}{$this->docker_compose_location} up -d";
$this->execute_remote_command( $this->execute_remote_command(
[executeInDocker($this->deployment_uuid, $command), 'hidden' => true], [executeInDocker($this->deployment_uuid, $command), 'hidden' => true],
@ -748,9 +749,18 @@ private function deploy_dockerfile_buildpack()
} }
$this->cleanup_git(); $this->cleanup_git();
$this->generate_compose_file(); $this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables(); $this->generate_build_env_variables();
$this->add_build_env_variables_to_dockerfile(); $this->add_build_env_variables_to_dockerfile();
$this->build_image(); $this->build_image();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry(); $this->push_to_docker_registry();
$this->rolling_update(); $this->rolling_update();
} }
@ -774,11 +784,15 @@ private function deploy_nixpacks_buildpack()
$this->cleanup_git(); $this->cleanup_git();
$this->generate_nixpacks_confs(); $this->generate_nixpacks_confs();
$this->generate_compose_file(); $this->generate_compose_file();
// Save build-time .env file BEFORE the build for Nixpacks
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables(); $this->generate_build_env_variables();
$this->build_image(); $this->build_image();
// For Nixpacks, save runtime environment variables AFTER the build // For Nixpacks, save runtime environment variables AFTER the build
// to prevent them from being accessible during the build process // This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables(); $this->save_runtime_environment_variables();
$this->push_to_docker_registry(); $this->push_to_docker_registry();
$this->rolling_update(); $this->rolling_update();
@ -802,7 +816,16 @@ private function deploy_static_buildpack()
$this->clone_repository(); $this->clone_repository();
$this->cleanup_git(); $this->cleanup_git();
$this->generate_compose_file(); $this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->build_static_image(); $this->build_static_image();
// Save runtime environment variables AFTER the build
// This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables();
$this->push_to_docker_registry(); $this->push_to_docker_registry();
$this->rolling_update(); $this->rolling_update();
} }
@ -934,7 +957,13 @@ private function generate_image_names()
$this->production_image_name = "{$this->application->uuid}:latest"; $this->production_image_name = "{$this->application->uuid}:latest";
} }
} elseif ($this->application->build_pack === 'dockerimage') { } elseif ($this->application->build_pack === 'dockerimage') {
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}"; // Check if this is an image hash deployment
if (str($this->dockerImageTag)->startsWith('sha256-')) {
$hash = str($this->dockerImageTag)->after('sha256-');
$this->production_image_name = "{$this->dockerImage}@sha256:{$hash}";
} else {
$this->production_image_name = "{$this->dockerImage}:{$this->dockerImageTag}";
}
} elseif ($this->pull_request_id !== 0) { } elseif ($this->pull_request_id !== 0) {
if ($this->application->docker_registry_image_name) { if ($this->application->docker_registry_image_name) {
$this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build"; $this->build_image_name = "{$this->application->docker_registry_image_name}:pr-{$this->pull_request_id}-build";
@ -1049,8 +1078,6 @@ private function generate_runtime_environment_variables()
$envs->push($key.'='.$item); $envs->push($key.'='.$item);
}); });
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
$this->env_filename = '.env';
// Generate SERVICE_ variables first for dockercompose // Generate SERVICE_ variables first for dockercompose
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]); $domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
@ -1109,8 +1136,6 @@ private function generate_runtime_environment_variables()
$envs->push('HOST=0.0.0.0'); $envs->push('HOST=0.0.0.0');
} }
} else { } else {
$this->env_filename = '.env';
// Generate SERVICE_ variables first for dockercompose preview // Generate SERVICE_ variables first for dockercompose preview
if ($this->build_pack === 'dockercompose') { if ($this->build_pack === 'dockercompose') {
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]); $domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
@ -1165,99 +1190,250 @@ private function generate_runtime_environment_variables()
$envs->push('HOST=0.0.0.0'); $envs->push('HOST=0.0.0.0');
} }
} }
if ($envs->isEmpty()) {
if ($this->env_filename) {
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
$this->server = $this->build_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
} else {
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/{$this->env_filename}",
'hidden' => true,
'ignore_errors' => true,
]
);
}
}
$this->env_filename = null;
} else {
// For Nixpacks builds, we save the .env file AFTER the build to prevent
// runtime-only variables from being accessible during the build process
if ($this->application->build_pack !== 'nixpacks' || $this->skip_build) {
$envs_base64 = base64_encode($envs->implode("\n"));
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"),
],
); // Return the generated environment variables instead of storing them globally
if ($this->use_build_server) { return $envs;
$this->server = $this->original_server;
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
}
}
}
$this->environment_variables = $envs;
} }
private function save_runtime_environment_variables() private function save_runtime_environment_variables()
{ {
// This method saves the .env file with runtime variables // This method saves the .env file with ALL runtime variables
// It should be called AFTER the build for Nixpacks to prevent runtime-only variables // For builds, it should be called AFTER the build to include runtime-only variables
// from being accessible during the build process
if ($this->environment_variables && $this->environment_variables->isNotEmpty() && $this->env_filename) { // Generate runtime environment variables locally
$envs_base64 = base64_encode($this->environment_variables->implode("\n")); $environment_variables = $this->generate_runtime_environment_variables();
// Write .env file to workdir (for container runtime) // Handle empty environment variables
if ($environment_variables->isEmpty()) {
// For Docker Compose, we need to create an empty .env file
// because we always reference it in the compose file
if ($this->build_pack === 'dockercompose') {
$this->application_deployment_queue->addLogEntry('Creating empty .env file (no environment variables defined).');
// Create empty .env file
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"),
]
);
// Also create in configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
"touch $this->configuration_dir/.env",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"touch $this->configuration_dir/.env",
]
);
}
} else {
// For non-Docker Compose deployments, clean up any existing .env files
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
$this->server = $this->build_server;
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
} else {
$this->execute_remote_command(
[
'command' => "rm -f $this->configuration_dir/.env",
'hidden' => true,
'ignore_errors' => true,
]
);
}
}
return;
}
// Write the environment variables to file
$envs_base64 = base64_encode($environment_variables->implode("\n"));
// Write .env file to workdir (for container runtime)
$this->application_deployment_queue->addLogEntry('Creating .env file with runtime variables for build phase.', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"),
'hidden' => true,
]
);
// Write .env file to configuration directory
if ($this->use_build_server) {
$this->server = $this->original_server;
$this->execute_remote_command( $this->execute_remote_command(
[ [
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/{$this->env_filename} > /dev/null"), "echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/.env > /dev/null",
]
);
}
}
private function generate_buildtime_environment_variables()
{
$envs = collect([]);
$coolify_envs = $this->generate_coolify_env_variables();
// Add COOLIFY variables
$coolify_envs->each(function ($item, $key) use ($envs) {
$envs->push($key.'='.$item);
});
// Add SERVICE_NAME variables for Docker Compose builds
if ($this->build_pack === 'dockercompose') {
if ($this->pull_request_id === 0) {
// Generate SERVICE_NAME for dockercompose services from processed compose
if ($this->application->settings->is_raw_compose_deployment_enabled) {
$dockerCompose = Yaml::parse($this->application->docker_compose_raw);
} else {
$dockerCompose = Yaml::parse($this->application->docker_compose);
}
$services = data_get($dockerCompose, 'services', []);
foreach ($services as $serviceName => $_) {
$envs->push('SERVICE_NAME_'.str($serviceName)->upper().'='.$serviceName);
}
// Generate SERVICE_FQDN & SERVICE_URL for non-PR deployments
$domains = collect(json_decode($this->application->docker_compose_domains)) ?? collect([]);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
} else {
// Generate SERVICE_NAME for preview deployments
$rawDockerCompose = Yaml::parse($this->application->docker_compose_raw);
$rawServices = data_get($rawDockerCompose, 'services', []);
foreach ($rawServices as $rawServiceName => $_) {
$envs->push('SERVICE_NAME_'.str($rawServiceName)->upper().'='.addPreviewDeploymentSuffix($rawServiceName, $this->pull_request_id));
}
// Generate SERVICE_FQDN & SERVICE_URL for preview deployments with PR-specific domains
$domains = collect(json_decode(data_get($this->preview, 'docker_compose_domains'))) ?? collect([]);
foreach ($domains as $forServiceName => $domain) {
$parsedDomain = data_get($domain, 'domain');
if (filled($parsedDomain)) {
$parsedDomain = str($parsedDomain)->explode(',')->first();
$coolifyUrl = Url::fromString($parsedDomain);
$coolifyScheme = $coolifyUrl->getScheme();
$coolifyFqdn = $coolifyUrl->getHost();
$coolifyUrl = $coolifyUrl->withScheme($coolifyScheme)->withHost($coolifyFqdn)->withPort(null);
$envs->push('SERVICE_URL_'.str($forServiceName)->upper().'='.$coolifyUrl->__toString());
$envs->push('SERVICE_FQDN_'.str($forServiceName)->upper().'='.$coolifyFqdn);
}
}
}
}
// Add build-time user variables only
if ($this->pull_request_id === 0) {
$sorted_environment_variables = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
} else {
$sorted_environment_variables = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) // ONLY build-time variables
->orderBy($this->application->settings->is_env_sorting_enabled ? 'key' : 'id')
->get();
// For Docker Compose, filter out SERVICE_FQDN and SERVICE_URL as we generate these with PR-specific values
if ($this->build_pack === 'dockercompose') {
$sorted_environment_variables = $sorted_environment_variables->filter(function ($env) {
return ! str($env->key)->startsWith('SERVICE_FQDN_') && ! str($env->key)->startsWith('SERVICE_URL_');
});
}
foreach ($sorted_environment_variables as $env) {
$envs->push($env->key.'='.$env->real_value);
}
}
// Return the generated environment variables
return $envs;
}
private function save_buildtime_environment_variables()
{
// Generate build-time environment variables locally
$environment_variables = $this->generate_buildtime_environment_variables();
// Save .env file for build phase
if ($environment_variables->isNotEmpty()) {
$envs_base64 = base64_encode($environment_variables->implode("\n"));
$this->application_deployment_queue->addLogEntry('Creating .env file with build-time variables for build phase.', hidden: true);
$this->execute_remote_command(
[
executeInDocker($this->deployment_uuid, "echo '$envs_base64' | base64 -d | tee $this->workdir/.env > /dev/null"),
],
[
executeInDocker($this->deployment_uuid, "cat $this->workdir/.env"),
'hidden' => true,
], ],
); );
} elseif ($this->build_pack === 'dockercompose') {
// For Docker Compose, create an empty .env file even if there are no build-time variables
// This ensures the file exists when referenced in docker-compose commands
$this->application_deployment_queue->addLogEntry('Creating empty .env file for build phase (no build-time variables defined).', hidden: true);
// Write .env file to configuration directory $this->execute_remote_command(
if ($this->use_build_server) { [
$this->server = $this->original_server; executeInDocker($this->deployment_uuid, "touch $this->workdir/.env"),
$this->execute_remote_command( ]
[ );
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
$this->server = $this->build_server;
} else {
$this->execute_remote_command(
[
"echo '$envs_base64' | base64 -d | tee $this->configuration_dir/{$this->env_filename} > /dev/null",
]
);
}
} }
} }
@ -1472,15 +1648,18 @@ private function deploy_pull_request()
$this->generate_nixpacks_confs(); $this->generate_nixpacks_confs();
} }
$this->generate_compose_file(); $this->generate_compose_file();
// Save build-time .env file BEFORE the build
$this->save_buildtime_environment_variables();
$this->generate_build_env_variables(); $this->generate_build_env_variables();
if ($this->application->build_pack === 'dockerfile') { if ($this->application->build_pack === 'dockerfile') {
$this->add_build_env_variables_to_dockerfile(); $this->add_build_env_variables_to_dockerfile();
} }
$this->build_image(); $this->build_image();
// For Nixpacks, save runtime environment variables AFTER the build
if ($this->application->build_pack === 'nixpacks') { // This overwrites the build-time .env with ALL variables (build-time + runtime)
$this->save_runtime_environment_variables(); $this->save_runtime_environment_variables();
}
$this->push_to_docker_registry(); $this->push_to_docker_registry();
$this->rolling_update(); $this->rolling_update();
} }
@ -1526,7 +1705,6 @@ private function prepare_builder_image()
$this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server); $this->dockerConfigFileExists = instant_remote_process(["test -f {$this->serverUserHomeDir}/.docker/config.json && echo 'OK' || echo 'NOK'"], $this->server);
$env_flags = $this->generate_docker_env_flags_for_secrets(); $env_flags = $this->generate_docker_env_flags_for_secrets();
if ($this->use_build_server) { if ($this->use_build_server) {
if ($this->dockerConfigFileExists === 'NOK') { if ($this->dockerConfigFileExists === 'NOK') {
throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.'); throw new RuntimeException('Docker config file (~/.docker/config.json) not found on the build server. Please run "docker login" to login to the docker registry on the server.');
@ -1553,6 +1731,18 @@ private function prepare_builder_image()
$this->run_pre_deployment_command(); $this->run_pre_deployment_command();
} }
private function restart_builder_container_with_actual_commit()
{
// Stop and remove the current helper container
$this->graceful_shutdown_container($this->deployment_uuid);
// Clear cached env_args to force regeneration with actual SOURCE_COMMIT value
$this->env_args = null;
// Restart the helper container with updated environment variables (including actual SOURCE_COMMIT)
$this->prepare_builder_image();
}
private function deploy_to_additional_destinations() private function deploy_to_additional_destinations()
{ {
if ($this->application->additional_networks->count() === 0) { if ($this->application->additional_networks->count() === 0) {
@ -1621,6 +1811,8 @@ private function set_coolify_variables()
if (isset($this->application->git_branch)) { if (isset($this->application->git_branch)) {
$this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} "; $this->coolify_variables .= "COOLIFY_BRANCH={$this->application->git_branch} ";
} }
$this->coolify_variables .= "COOLIFY_RESOURCE_UUID={$this->application->uuid} ";
$this->coolify_variables .= "COOLIFY_CONTAINER_NAME={$this->container_name} ";
} }
private function check_git_if_build_needed() private function check_git_if_build_needed()
@ -1688,6 +1880,12 @@ private function check_git_if_build_needed()
$this->application_deployment_queue->save(); $this->application_deployment_queue->save();
} }
$this->set_coolify_variables(); $this->set_coolify_variables();
// Restart helper container with actual SOURCE_COMMIT value
if ($this->application->settings->use_build_secrets && $this->commit !== 'HEAD') {
$this->application_deployment_queue->addLogEntry('Restarting helper container with actual SOURCE_COMMIT value.');
$this->restart_builder_container_with_actual_commit();
}
} }
private function clone_repository() private function clone_repository()
@ -1935,11 +2133,14 @@ private function generate_env_variables()
{ {
$this->env_args = collect([]); $this->env_args = collect([]);
$this->env_args->put('SOURCE_COMMIT', $this->commit); $this->env_args->put('SOURCE_COMMIT', $this->commit);
$coolify_envs = $this->generate_coolify_env_variables(); $coolify_envs = $this->generate_coolify_env_variables();
$coolify_envs->each(function ($value, $key) {
$this->env_args->put($key, $value);
});
// For build process, include only environment variables where is_buildtime = true // For build process, include only environment variables where is_buildtime = true
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
// Get environment variables that are marked as available during build
$envs = $this->application->environment_variables() $envs = $this->application->environment_variables()
->where('key', 'not like', 'NIXPACKS_%') ->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) ->where('is_buildtime', true)
@ -1948,24 +2149,9 @@ private function generate_env_variables()
foreach ($envs as $env) { foreach ($envs as $env) {
if (! is_null($env->real_value)) { if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value); $this->env_args->put($env->key, $env->real_value);
if (str($env->real_value)->startsWith('$')) {
$variable_key = str($env->real_value)->after('$');
if ($variable_key->startsWith('COOLIFY_')) {
$variable = $coolify_envs->get($variable_key->value());
if (filled($variable)) {
$this->env_args->prepend($variable, $variable_key->value());
}
} else {
$variable = $this->application->environment_variables()->where('key', $variable_key)->first();
if ($variable) {
$this->env_args->prepend($variable->real_value, $env->key);
}
}
}
} }
} }
} else { } else {
// Get preview environment variables that are marked as available during build
$envs = $this->application->environment_variables_preview() $envs = $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%') ->where('key', 'not like', 'NIXPACKS_%')
->where('is_buildtime', true) ->where('is_buildtime', true)
@ -1974,20 +2160,6 @@ private function generate_env_variables()
foreach ($envs as $env) { foreach ($envs as $env) {
if (! is_null($env->real_value)) { if (! is_null($env->real_value)) {
$this->env_args->put($env->key, $env->real_value); $this->env_args->put($env->key, $env->real_value);
if (str($env->real_value)->startsWith('$')) {
$variable_key = str($env->real_value)->after('$');
if ($variable_key->startsWith('COOLIFY_')) {
$variable = $coolify_envs->get($variable_key->value());
if (filled($variable)) {
$this->env_args->prepend($variable, $variable_key->value());
}
} else {
$variable = $this->application->environment_variables_preview()->where('key', $variable_key)->first();
if ($variable) {
$this->env_args->prepend($variable->real_value, $env->key);
}
}
}
} }
} }
} }
@ -2001,7 +2173,6 @@ private function generate_compose_file()
$persistent_storages = $this->generate_local_persistent_volumes(); $persistent_storages = $this->generate_local_persistent_volumes();
$persistent_file_volumes = $this->application->fileStorages()->get(); $persistent_file_volumes = $this->application->fileStorages()->get();
$volume_names = $this->generate_local_persistent_volumes_only_volume_names(); $volume_names = $this->generate_local_persistent_volumes_only_volume_names();
$this->generate_runtime_environment_variables();
if (data_get($this->application, 'custom_labels')) { if (data_get($this->application, 'custom_labels')) {
$this->application->parseContainerLabels(); $this->application->parseContainerLabels();
$labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels))); $labels = collect(preg_split("/\r\n|\n|\r/", base64_decode($this->application->custom_labels)));
@ -2070,9 +2241,8 @@ private function generate_compose_file()
], ],
], ],
]; ];
if (filled($this->env_filename)) { // Always use .env file
$docker_compose['services'][$this->container_name]['env_file'] = [$this->env_filename]; $docker_compose['services'][$this->container_name]['env_file'] = ['.env'];
}
$docker_compose['services'][$this->container_name]['healthcheck'] = [ $docker_compose['services'][$this->container_name]['healthcheck'] = [
'test' => [ 'test' => [
'CMD-SHELL', 'CMD-SHELL',
@ -2364,13 +2534,12 @@ private function build_image()
// Coolify variables are already included in the secrets from generate_build_env_variables // Coolify variables are already included in the secrets from generate_build_env_variables
// build_secrets is already a string at this point // build_secrets is already a string at this point
} else { } else {
// Traditional build args approach // Traditional build args approach - generate COOLIFY_ variables locally
$this->environment_variables->filter(function ($key, $value) { // Generate COOLIFY_ variables locally for build args
return str($key)->startsWith('COOLIFY_'); $coolify_envs = $this->generate_coolify_env_variables();
})->each(function ($key, $value) { $coolify_envs->each(function ($value, $key) {
$this->build_args->push("--build-arg '{$key}'"); $this->build_args->push("--build-arg '{$key}'");
}); });
$this->build_args = $this->build_args instanceof \Illuminate\Support\Collection $this->build_args = $this->build_args instanceof \Illuminate\Support\Collection
? $this->build_args->implode(' ') ? $this->build_args->implode(' ')
: (string) $this->build_args; : (string) $this->build_args;
@ -2610,6 +2779,8 @@ private function build_image()
} else { } else {
// Dockerfile buildpack // Dockerfile buildpack
if ($this->dockerBuildkitSupported) { if ($this->dockerBuildkitSupported) {
// Modify the Dockerfile to use build secrets
$this->modify_dockerfile_for_secrets("{$this->workdir}{$this->dockerfile_location}");
// Use BuildKit with secrets // Use BuildKit with secrets
$secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : ''; $secrets_flags = $this->build_secrets ? " {$this->build_secrets}" : '';
if ($this->force_rebuild) { if ($this->force_rebuild) {
@ -2764,7 +2935,6 @@ private function generate_build_env_variables()
$this->generate_env_variables(); $this->generate_env_variables();
$variables = collect([])->merge($this->env_args); $variables = collect([])->merge($this->env_args);
} }
// Analyze build variables for potential issues // Analyze build variables for potential issues
if ($variables->isNotEmpty()) { if ($variables->isNotEmpty()) {
$this->analyzeBuildTimeVariables($variables); $this->analyzeBuildTimeVariables($variables);
@ -2809,9 +2979,13 @@ private function generate_docker_env_flags_for_secrets()
return ''; return '';
} }
$variables = $this->pull_request_id === 0 // Generate env variables if not already done
? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() // This populates $this->env_args with both user-defined and COOLIFY_* variables
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); if (! $this->env_args || $this->env_args->isEmpty()) {
$this->generate_env_variables();
}
$variables = $this->env_args;
if ($variables->isEmpty()) { if ($variables->isEmpty()) {
return ''; return '';
@ -2819,12 +2993,19 @@ private function generate_docker_env_flags_for_secrets()
$secrets_hash = $this->generate_secrets_hash($variables); $secrets_hash = $this->generate_secrets_hash($variables);
// Get database env vars to check for multiline flag
$env_vars = $this->pull_request_id === 0
? $this->application->environment_variables()->where('is_buildtime', true)->get()
: $this->application->environment_variables_preview()->where('is_buildtime', true)->get();
// Map to simple array format for the helper function // Map to simple array format for the helper function
$vars_array = $variables->map(function ($env) { $vars_array = $variables->map(function ($value, $key) use ($env_vars) {
$env = $env_vars->firstWhere('key', $key);
return [ return [
'key' => $env->key, 'key' => $key,
'value' => $env->real_value, 'value' => $value,
'is_multiline' => $env->is_multiline, 'is_multiline' => $env ? $env->is_multiline : false,
]; ];
}); });
@ -2888,9 +3069,9 @@ private function add_build_env_variables_to_dockerfile()
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"), executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true, 'hidden' => true,
'save' => 'dockerfile', 'save' => 'dockerfile',
'ignore_errors' => true,
]); ]);
$dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n")); $dockerfile = collect(str($this->saved_outputs->get('dockerfile'))->trim()->explode("\n"));
if ($this->pull_request_id === 0) { if ($this->pull_request_id === 0) {
// Only add environment variables that are available during build // Only add environment variables that are available during build
$envs = $this->application->environment_variables() $envs = $this->application->environment_variables()
@ -2947,10 +3128,17 @@ private function add_build_env_variables_to_dockerfile()
} }
$dockerfile_base64 = base64_encode($dockerfile->implode("\n")); $dockerfile_base64 = base64_encode($dockerfile->implode("\n"));
$this->execute_remote_command([ $this->application_deployment_queue->addLogEntry('Final Dockerfile:', type: 'info');
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"), $this->execute_remote_command(
'hidden' => true, [
]); executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$this->workdir}{$this->dockerfile_location} > /dev/null"),
'hidden' => true,
],
[
executeInDocker($this->deployment_uuid, "cat {$this->workdir}{$this->dockerfile_location}"),
'hidden' => true,
'ignore_errors' => true,
]);
} }
} }
@ -2975,16 +3163,19 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
$dockerfile->prepend('# syntax=docker/dockerfile:1'); $dockerfile->prepend('# syntax=docker/dockerfile:1');
} }
// Get environment variables for secrets // Generate env variables if not already done
$variables = $this->pull_request_id === 0 // This populates $this->env_args with both user-defined and COOLIFY_* variables
? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get() if (! $this->env_args || $this->env_args->isEmpty()) {
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->where('is_buildtime', true)->get(); $this->generate_env_variables();
}
$variables = $this->env_args;
if ($variables->isEmpty()) { if ($variables->isEmpty()) {
return; return;
} }
// Generate mount strings for all secrets // Generate mount strings for all secrets
$mountStrings = $variables->map(fn ($env) => "--mount=type=secret,id={$env->key},env={$env->key}")->implode(' '); $mountStrings = $variables->map(fn ($value, $key) => "--mount=type=secret,id={$key},env={$key}")->implode(' ');
// Add mount for the secrets hash to ensure cache invalidation // Add mount for the secrets hash to ensure cache invalidation
$mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH'; $mountStrings .= ' --mount=type=secret,id=COOLIFY_BUILD_SECRETS_HASH,env=COOLIFY_BUILD_SECRETS_HASH';
@ -3012,8 +3203,6 @@ private function modify_dockerfile_for_secrets($dockerfile_path)
executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"), executeInDocker($this->deployment_uuid, "echo '{$dockerfile_base64}' | base64 -d | tee {$dockerfile_path} > /dev/null"),
'hidden' => true, 'hidden' => true,
]); ]);
$this->application_deployment_queue->addLogEntry('Modified Dockerfile to use build secrets.');
} }
} }
@ -3023,15 +3212,13 @@ private function modify_dockerfiles_for_compose($composeFile)
return; return;
} }
$variables = $this->pull_request_id === 0 // Generate env variables if not already done
? $this->application->environment_variables() // This populates $this->env_args with both user-defined and COOLIFY_* variables
->where('key', 'not like', 'NIXPACKS_%') if (! $this->env_args || $this->env_args->isEmpty()) {
->where('is_buildtime', true) $this->generate_env_variables();
->get() }
: $this->application->environment_variables_preview()
->where('key', 'not like', 'NIXPACKS_%') $variables = $this->env_args;
->where('is_buildtime', true)
->get();
if ($variables->isEmpty()) { if ($variables->isEmpty()) {
$this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.'); $this->application_deployment_queue->addLogEntry('No build-time variables to add to Dockerfiles.');
@ -3105,11 +3292,10 @@ private function modify_dockerfiles_for_compose($composeFile)
$isMultiStage = count($fromIndices) > 1; $isMultiStage = count($fromIndices) > 1;
$argsToAdd = collect([]); $argsToAdd = collect([]);
foreach ($variables as $env) { foreach ($variables as $key => $value) {
$argsToAdd->push("ARG {$env->key}"); $argsToAdd->push("ARG {$key}");
} }
ray($argsToAdd);
if ($argsToAdd->isEmpty()) { if ($argsToAdd->isEmpty()) {
$this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add."); $this->application_deployment_queue->addLogEntry("Service {$serviceName}: No build-time variables to add.");
@ -3177,19 +3363,22 @@ private function modify_dockerfiles_for_compose($composeFile)
private function add_build_secrets_to_compose($composeFile) private function add_build_secrets_to_compose($composeFile)
{ {
// Get environment variables for secrets // Generate env variables if not already done
$variables = $this->pull_request_id === 0 // This populates $this->env_args with both user-defined and COOLIFY_* variables
? $this->application->environment_variables()->where('key', 'not like', 'NIXPACKS_%')->get() if (! $this->env_args || $this->env_args->isEmpty()) {
: $this->application->environment_variables_preview()->where('key', 'not like', 'NIXPACKS_%')->get(); $this->generate_env_variables();
}
$variables = $this->env_args;
if ($variables->isEmpty()) { if ($variables->isEmpty()) {
return $composeFile; return $composeFile;
} }
$secrets = []; $secrets = [];
foreach ($variables as $env) { foreach ($variables as $key => $value) {
$secrets[$env->key] = [ $secrets[$key] = [
'environment' => $env->key, 'environment' => $key,
]; ];
} }
@ -3204,9 +3393,9 @@ private function add_build_secrets_to_compose($composeFile)
if (! isset($service['build']['secrets'])) { if (! isset($service['build']['secrets'])) {
$service['build']['secrets'] = []; $service['build']['secrets'] = [];
} }
foreach ($variables as $env) { foreach ($variables as $key => $value) {
if (! in_array($env->key, $service['build']['secrets'])) { if (! in_array($key, $service['build']['secrets'])) {
$service['build']['secrets'][] = $env->key; $service['build']['secrets'][] = $key;
} }
} }
} }
@ -3325,7 +3514,6 @@ private function next(string $status)
queue_next_deployment($this->application); queue_next_deployment($this->application);
if ($status === ApplicationDeploymentStatus::FINISHED->value) { if ($status === ApplicationDeploymentStatus::FINISHED->value) {
ray($this->application->team()->id);
event(new ApplicationConfigurationChanged($this->application->team()->id)); event(new ApplicationConfigurationChanged($this->application->team()->id));
if (! $this->only_this_server) { if (! $this->only_this_server) {

View file

@ -16,7 +16,8 @@ public function deployments()
{ {
$servers = Server::ownedByCurrentTeam()->get(); $servers = Server::ownedByCurrentTeam()->get();
return ApplicationDeploymentQueue::whereIn('status', ['in_progress', 'queued']) return ApplicationDeploymentQueue::with(['application.environment.project'])
->whereIn('status', ['in_progress', 'queued'])
->whereIn('server_id', $servers->pluck('id')) ->whereIn('server_id', $servers->pluck('id'))
->orderBy('id') ->orderBy('id')
->get([ ->get([

View file

@ -50,6 +50,28 @@ public function force_start()
} }
} }
public function copyLogsToClipboard(): string
{
$logs = json_decode($this->application_deployment_queue->logs, associative: true, flags: JSON_THROW_ON_ERROR);
if (! $logs) {
return '';
}
$markdown = "# Deployment Logs\n\n";
$markdown .= "```\n";
foreach ($logs as $log) {
if (isset($log['output'])) {
$markdown .= $log['output']."\n";
}
}
$markdown .= "```\n";
return $markdown;
}
public function cancel() public function cancel()
{ {
$deployment_uuid = $this->application_deployment_queue->deployment_uuid; $deployment_uuid = $this->application_deployment_queue->deployment_uuid;

View file

@ -12,7 +12,11 @@
class DockerImage extends Component class DockerImage extends Component
{ {
public string $dockerImage = ''; public string $imageName = '';
public string $imageTag = '';
public string $imageSha256 = '';
public array $parameters; public array $parameters;
@ -26,12 +30,41 @@ public function mount()
public function submit() public function submit()
{ {
// Strip 'sha256:' prefix if user pasted it
if ($this->imageSha256) {
$this->imageSha256 = preg_replace('/^sha256:/i', '', trim($this->imageSha256));
}
// Remove @sha256 from image name if user added it
if ($this->imageName) {
$this->imageName = preg_replace('/@sha256$/i', '', trim($this->imageName));
}
$this->validate([ $this->validate([
'dockerImage' => 'required', 'imageName' => ['required', 'string'],
'imageTag' => ['nullable', 'string', 'regex:/^[a-z0-9][a-z0-9._-]*$/i'],
'imageSha256' => ['nullable', 'string', 'regex:/^[a-f0-9]{64}$/i'],
]); ]);
// Validate that either tag or sha256 is provided, but not both
if ($this->imageTag && $this->imageSha256) {
$this->addError('imageTag', 'Provide either a tag or SHA256 digest, not both.');
$this->addError('imageSha256', 'Provide either a tag or SHA256 digest, not both.');
return;
}
// Build the full Docker image string
if ($this->imageSha256) {
$dockerImage = $this->imageName.'@sha256:'.$this->imageSha256;
} elseif ($this->imageTag) {
$dockerImage = $this->imageName.':'.$this->imageTag;
} else {
$dockerImage = $this->imageName.':latest';
}
$parser = new DockerImageParser; $parser = new DockerImageParser;
$parser->parse($this->dockerImage); $parser->parse($dockerImage);
$destination_uuid = $this->query['destination']; $destination_uuid = $this->query['destination'];
$destination = StandaloneDocker::where('uuid', $destination_uuid)->first(); $destination = StandaloneDocker::where('uuid', $destination_uuid)->first();
@ -45,6 +78,16 @@ public function submit()
$project = Project::where('uuid', $this->parameters['project_uuid'])->first(); $project = Project::where('uuid', $this->parameters['project_uuid'])->first();
$environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first(); $environment = $project->load(['environments'])->environments->where('uuid', $this->parameters['environment_uuid'])->first();
// Determine the image tag based on whether it's a hash or regular tag
$imageTag = $parser->isImageHash() ? 'sha256-'.$parser->getTag() : $parser->getTag();
// Append @sha256 to image name if using digest and not already present
$imageName = $parser->getFullImageNameWithoutTag();
if ($parser->isImageHash() && ! str_ends_with($imageName, '@sha256')) {
$imageName .= '@sha256';
}
$application = Application::create([ $application = Application::create([
'name' => 'docker-image-'.new Cuid2, 'name' => 'docker-image-'.new Cuid2,
'repository_project_id' => 0, 'repository_project_id' => 0,
@ -52,7 +95,7 @@ public function submit()
'git_branch' => 'main', 'git_branch' => 'main',
'build_pack' => 'dockerimage', 'build_pack' => 'dockerimage',
'ports_exposes' => 80, 'ports_exposes' => 80,
'docker_registry_image_name' => $parser->getFullImageNameWithoutTag(), 'docker_registry_image_name' => $imageName,
'docker_registry_image_tag' => $parser->getTag(), 'docker_registry_image_tag' => $parser->getTag(),
'environment_id' => $environment->id, 'environment_id' => $environment->id,
'destination_id' => $destination->id, 'destination_id' => $destination->id,

View file

@ -34,6 +34,8 @@ class FileStorage extends Component
public bool $permanently_delete = true; public bool $permanently_delete = true;
public bool $isReadOnly = false;
protected $rules = [ protected $rules = [
'fileStorage.is_directory' => 'required', 'fileStorage.is_directory' => 'required',
'fileStorage.fs_path' => 'required', 'fileStorage.fs_path' => 'required',
@ -52,6 +54,8 @@ public function mount()
$this->workdir = null; $this->workdir = null;
$this->fs_path = $this->fileStorage->fs_path; $this->fs_path = $this->fileStorage->fs_path;
} }
$this->isReadOnly = $this->fileStorage->isReadOnlyVolume();
} }
public function convertToDirectory() public function convertToDirectory()

View file

@ -37,6 +37,11 @@ class Show extends Component
'host_path' => 'host', 'host_path' => 'host',
]; ];
public function mount()
{
$this->isReadOnly = $this->storage->isReadOnlyVolume();
}
public function submit() public function submit()
{ {
$this->authorize('update', $this->resource); $this->authorize('update', $this->resource);

View file

@ -740,8 +740,8 @@ public function environment_variables()
->where('is_preview', false) ->where('is_preview', false)
->orderByRaw(" ->orderByRaw("
CASE CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1 WHEN is_required = true THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3 ELSE 3
END, END,
LOWER(key) ASC LOWER(key) ASC
@ -768,8 +768,8 @@ public function environment_variables_preview()
->where('is_preview', true) ->where('is_preview', true)
->orderByRaw(" ->orderByRaw("
CASE CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1 WHEN is_required = true THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3 ELSE 3
END, END,
LOWER(key) ASC LOWER(key) ASC

View file

@ -41,11 +41,9 @@ class ApplicationDeploymentQueue extends Model
{ {
protected $guarded = []; protected $guarded = [];
public function application(): Attribute public function application()
{ {
return Attribute::make( return $this->belongsTo(Application::class);
get: fn () => Application::find($this->application_id),
);
} }
public function server(): Attribute public function server(): Attribute

View file

@ -5,6 +5,7 @@
use App\Events\FileStorageChanged; use App\Events\FileStorageChanged;
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Factories\HasFactory; use Illuminate\Database\Eloquent\Factories\HasFactory;
use Symfony\Component\Yaml\Yaml;
class LocalFileVolume extends BaseModel class LocalFileVolume extends BaseModel
{ {
@ -192,4 +193,61 @@ public function scopeWherePlainMountPath($query, $path)
{ {
return $query->get()->where('plain_mount_path', $path); return $query->get()->where('plain_mount_path', $path);
} }
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
try {
// Only check for services
$service = $this->service;
if (! $service || ! method_exists($service, 'service')) {
return false;
}
$actualService = $service->service;
if (! $actualService || ! $actualService->docker_compose_raw) {
return false;
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
// Find the service that this volume belongs to
$serviceName = $service->name;
if (! isset($compose['services'][$serviceName]['volumes'])) {
return false;
}
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our fs_path and mount_path
if (count($parts) >= 2) {
$hostPath = $parts[0];
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on fs_path and mount_path
if ($hostPath === $this->fs_path && $containerPath === $this->mount_path) {
return $options === 'ro';
}
}
}
}
return false;
} catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only volume');
return false;
}
}
} }

View file

@ -4,6 +4,7 @@
use Illuminate\Database\Eloquent\Casts\Attribute; use Illuminate\Database\Eloquent\Casts\Attribute;
use Illuminate\Database\Eloquent\Model; use Illuminate\Database\Eloquent\Model;
use Symfony\Component\Yaml\Yaml;
class LocalPersistentVolume extends Model class LocalPersistentVolume extends Model
{ {
@ -48,4 +49,69 @@ protected function hostPath(): Attribute
} }
); );
} }
// Check if this volume is read-only by parsing the docker-compose content
public function isReadOnlyVolume(): bool
{
try {
// Get the resource (can be application, service, or database)
$resource = $this->resource;
if (! $resource) {
return false;
}
// Only check for services
if (! method_exists($resource, 'service')) {
return false;
}
$actualService = $resource->service;
if (! $actualService || ! $actualService->docker_compose_raw) {
return false;
}
// Parse the docker-compose content
$compose = Yaml::parse($actualService->docker_compose_raw);
if (! isset($compose['services'])) {
return false;
}
// Find the service that this volume belongs to
$serviceName = $resource->name;
if (! isset($compose['services'][$serviceName]['volumes'])) {
return false;
}
$volumes = $compose['services'][$serviceName]['volumes'];
// Check each volume to find a match
foreach ($volumes as $volume) {
// Volume can be string like "host:container:ro" or "host:container"
if (is_string($volume)) {
$parts = explode(':', $volume);
// Check if this volume matches our mount_path
if (count($parts) >= 2) {
$containerPath = $parts[1];
$options = $parts[2] ?? null;
// Match based on mount_path
// Remove leading slash from mount_path if present for comparison
$mountPath = str($this->mount_path)->ltrim('/')->toString();
$containerPathClean = str($containerPath)->ltrim('/')->toString();
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
return $options === 'ro';
}
}
}
}
return false;
} catch (\Throwable $e) {
ray($e->getMessage(), 'Error checking read-only persistent volume');
return false;
}
}
} }

View file

@ -547,6 +547,21 @@ public function extraFields()
} }
$fields->put('Grafana', $data->toArray()); $fields->put('Grafana', $data->toArray());
break; 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'): case $image->contains('directus'):
$data = collect([]); $data = collect([]);
$admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first(); $admin_email = $this->environment_variables()->where('key', 'ADMIN_EMAIL')->first();
@ -1232,8 +1247,8 @@ public function environment_variables()
return $this->morphMany(EnvironmentVariable::class, 'resourceable') return $this->morphMany(EnvironmentVariable::class, 'resourceable')
->orderByRaw(" ->orderByRaw("
CASE CASE
WHEN LOWER(key) LIKE 'service_%' THEN 1 WHEN is_required = true THEN 1
WHEN is_required = true AND (value IS NULL OR value = '') THEN 2 WHEN LOWER(key) LIKE 'service_%' THEN 2
ELSE 3 ELSE 3
END, END,
LOWER(key) ASC LOWER(key) ASC
@ -1263,6 +1278,21 @@ public function saveComposeConfigs()
$commands[] = "cd $workdir"; $commands[] = "cd $workdir";
$commands[] = 'rm -f .env || true'; $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(); $envs_from_coolify = $this->environment_variables()->get();
$sorted = $envs_from_coolify->sortBy(function ($env) { $sorted = $envs_from_coolify->sortBy(function ($env) {
if (str($env->key)->startsWith('SERVICE_')) { if (str($env->key)->startsWith('SERVICE_')) {
@ -1274,7 +1304,6 @@ public function saveComposeConfigs()
return 3; return 3;
}); });
$envs = collect([]);
foreach ($sorted as $env) { foreach ($sorted as $env) {
$envs->push("{$env->key}={$env->real_value}"); $envs->push("{$env->key}={$env->real_value}");
} }

View file

@ -0,0 +1,41 @@
<?php
namespace App\Rules;
use Closure;
use Illuminate\Contracts\Validation\ValidationRule;
class DockerImageFormat implements ValidationRule
{
/**
* Run the validation rule.
*
* @param \Closure(string, ?string=): \Illuminate\Translation\PotentiallyTranslatedString $fail
*/
public function validate(string $attribute, mixed $value, Closure $fail): void
{
// Check if the value contains ":sha256:" or ":sha" which is incorrect format
if (preg_match('/:sha256?:/i', $value)) {
$fail('The :attribute must use @ before sha256 digest (e.g., image@sha256:hash, not image:sha256:hash).');
return;
}
// Valid formats:
// 1. image:tag (e.g., nginx:latest)
// 2. registry/image:tag (e.g., ghcr.io/user/app:v1.2.3)
// 3. image@sha256:hash (e.g., nginx@sha256:abc123...)
// 4. registry/image@sha256:hash
// 5. registry:port/image:tag (e.g., localhost:5000/app:latest)
$pattern = '/^
(?:[a-z0-9]+(?:[._-][a-z0-9]+)*(?::[0-9]+)?\/)? # Optional registry with optional port
[a-z0-9]+(?:[._\/-][a-z0-9]+)* # Image name (required)
(?::[a-z0-9][a-z0-9._-]*|@sha256:[a-f0-9]{64})? # Optional :tag or @sha256:hash
$/ix';
if (! preg_match($pattern, $value)) {
$fail('The :attribute format is invalid. Use image:tag or image@sha256:hash format.');
}
}
}

View file

@ -10,20 +10,33 @@ class DockerImageParser
private string $tag = 'latest'; private string $tag = 'latest';
private bool $isImageHash = false;
public function parse(string $imageString): self public function parse(string $imageString): self
{ {
// First split by : to handle the tag, but be careful with registry ports // Check for @sha256: format first (e.g., nginx@sha256:abc123...)
$lastColon = strrpos($imageString, ':'); if (preg_match('/^(.+)@sha256:([a-f0-9]{64})$/i', $imageString, $matches)) {
$hasSlash = str_contains($imageString, '/'); $mainPart = $matches[1];
$this->tag = $matches[2];
// If the last colon appears after the last slash, it's a tag $this->isImageHash = true;
// Otherwise it might be a port in the registry URL
if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
$mainPart = substr($imageString, 0, $lastColon);
$this->tag = substr($imageString, $lastColon + 1);
} else { } else {
$mainPart = $imageString; // Split by : to handle the tag, but be careful with registry ports
$this->tag = 'latest'; $lastColon = strrpos($imageString, ':');
$hasSlash = str_contains($imageString, '/');
// If the last colon appears after the last slash, it's a tag
// Otherwise it might be a port in the registry URL
if ($lastColon !== false && (! $hasSlash || $lastColon > strrpos($imageString, '/'))) {
$mainPart = substr($imageString, 0, $lastColon);
$this->tag = substr($imageString, $lastColon + 1);
// Check if the tag is a SHA256 hash
$this->isImageHash = $this->isSha256Hash($this->tag);
} else {
$mainPart = $imageString;
$this->tag = 'latest';
$this->isImageHash = false;
}
} }
// Split the main part by / to handle registry and image name // Split the main part by / to handle registry and image name
@ -41,6 +54,37 @@ public function parse(string $imageString): self
return $this; return $this;
} }
/**
* Check if the given string is a SHA256 hash
*/
private function isSha256Hash(string $hash): bool
{
// SHA256 hashes are 64 characters long and contain only hexadecimal characters
return preg_match('/^[a-f0-9]{64}$/i', $hash) === 1;
}
/**
* Check if the current tag is an image hash
*/
public function isImageHash(): bool
{
return $this->isImageHash;
}
/**
* Get the full image name with hash if present
*/
public function getFullImageNameWithHash(): string
{
$imageName = $this->getFullImageNameWithoutTag();
if ($this->isImageHash) {
return $imageName.'@sha256:'.$this->tag;
}
return $imageName.':'.$this->tag;
}
public function getFullImageNameWithoutTag(): string public function getFullImageNameWithoutTag(): string
{ {
if ($this->registryUrl) { if ($this->registryUrl) {
@ -73,6 +117,10 @@ public function toString(): string
} }
$parts[] = $this->imageName; $parts[] = $this->imageName;
if ($this->isImageHash) {
return implode('/', $parts).'@sha256:'.$this->tag;
}
return implode('/', $parts).':'.$this->tag; return implode('/', $parts).':'.$this->tag;
} }
} }

View file

@ -1172,6 +1172,9 @@ function serviceParser(Service $resource): Collection
$parsedServices = collect([]); $parsedServices = collect([]);
// Generate SERVICE_NAME variables for docker compose services
$serviceNameEnvironments = generateDockerComposeServiceName($services);
$allMagicEnvironments = collect([]); $allMagicEnvironments = collect([]);
// Presave services // Presave services
foreach ($services as $serviceName => $service) { foreach ($services as $serviceName => $service) {
@ -1988,7 +1991,7 @@ function serviceParser(Service $resource): Collection
$payload['volumes'] = $volumesParsed; $payload['volumes'] = $volumesParsed;
} }
if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) { if ($environment->count() > 0 || $coolifyEnvironments->count() > 0) {
$payload['environment'] = $environment->merge($coolifyEnvironments); $payload['environment'] = $environment->merge($coolifyEnvironments)->merge($serviceNameEnvironments);
} }
if ($logging) { if ($logging) {
$payload['logging'] = $logging; $payload['logging'] = $logging;

View file

@ -2,7 +2,7 @@
return [ return [
'coolify' => [ 'coolify' => [
'version' => '4.0.0-beta.434', 'version' => '4.0.0-beta.435',
'helper_version' => '1.0.11', 'helper_version' => '1.0.11',
'realtime_version' => '1.0.10', 'realtime_version' => '1.0.10',
'self_hosted' => env('SELF_HOSTED', true), 'self_hosted' => env('SELF_HOSTED', true),

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.433" "version": "4.0.0-beta.435"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.434" "version": "4.0.0-beta.436"
}, },
"helper": { "helper": {
"version": "1.0.11" "version": "1.0.11"

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View file

@ -0,0 +1,15 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 256" width="256" height="256">
<!-- Official Ente Photos icon based on the official PNG -->
<defs>
<linearGradient id="gradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#00D4AA;stop-opacity:1" />
<stop offset="100%" style="stop-color:#00A693;stop-opacity:1" />
</linearGradient>
</defs>
<!-- Main circular background matching official Ente green -->
<circle cx="128" cy="128" r="120" fill="url(#gradient)"/>
<!-- Official Ente "e" letterform based on the PNG icon -->
<path d="M75 128 C75 90, 95 70, 128 70 C161 70, 181 90, 181 118 L85 118 C85 148, 105 168, 128 168 C146 168, 160 158, 170 144 L185 160 C172 178, 152 190, 128 190 C95 190, 75 170, 75 128 Z M85 102 L171 102 C167 84, 149 82, 128 82 C107 82, 89 84, 85 102 Z" fill="#FFFFFF"/>
</svg>

After

Width:  |  Height:  |  Size: 872 B

BIN
public/svgs/ente.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB

View file

@ -68,6 +68,9 @@ class="flex items-start gap-3 p-3 rounded-lg dark:bg-coolgray-200 bg-gray-50 tra
{{ $deployment->application_name }} {{ $deployment->application_name }}
</div> </div>
<p class="text-xs dark:text-neutral-400 text-gray-600 mt-1"> <p class="text-xs dark:text-neutral-400 text-gray-600 mt-1">
{{ $deployment->application?->environment?->project?->name }} / {{ $deployment->application?->environment?->name }}
</p>
<p class="text-xs dark:text-neutral-400 text-gray-600">
{{ $deployment->server_name }} {{ $deployment->server_name }}
</p> </p>
@if ($deployment->pull_request_id) @if ($deployment->pull_request_id)

View file

@ -5,6 +5,9 @@
@else @else
<x-forms.button wire:click.prevent="show_debug">Show Debug Logs</x-forms.button> <x-forms.button wire:click.prevent="show_debug">Show Debug Logs</x-forms.button>
@endif @endif
@if (isDev())
<x-forms.button x-on:click="$wire.copyLogsToClipboard().then(text => navigator.clipboard.writeText(text))">Copy Logs</x-forms.button>
@endif
@if (data_get($application_deployment_queue, 'status') === 'queued') @if (data_get($application_deployment_queue, 'status') === 'queued')
<x-forms.button wire:click.prevent="force_start">Force Start</x-forms.button> <x-forms.button wire:click.prevent="force_start">Force Start</x-forms.button>
@endif @endif

View file

@ -166,12 +166,14 @@ class="underline" href="https://coolify.io/docs/knowledge-base/docker/registry"
@if ($application->destination->server->isSwarm()) @if ($application->destination->server->isSwarm())
<x-forms.input required id="application.docker_registry_image_name" label="Docker Image" <x-forms.input required id="application.docker_registry_image_name" label="Docker Image"
x-bind:disabled="!canUpdate" /> x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag" <x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" /> x-bind:disabled="!canUpdate" />
@else @else
<x-forms.input id="application.docker_registry_image_name" label="Docker Image" <x-forms.input id="application.docker_registry_image_name" label="Docker Image"
x-bind:disabled="!canUpdate" /> x-bind:disabled="!canUpdate" />
<x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag" <x-forms.input id="application.docker_registry_image_tag" label="Docker Image Tag or Hash"
helper="Enter a tag (e.g., 'latest', 'v1.2.3') or SHA256 hash (e.g., 'sha256-59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0')"
x-bind:disabled="!canUpdate" /> x-bind:disabled="!canUpdate" />
@endif @endif
@else @else

View file

@ -1,4 +1,4 @@
<div> <div x-data x-init="$nextTick(() => { if ($refs.autofocusInput) $refs.autofocusInput.focus(); })">
<h1>Create a new Application</h1> <h1>Create a new Application</h1>
<div class="pb-4">You can deploy an existing Docker Image from any Registry.</div> <div class="pb-4">You can deploy an existing Docker Image from any Registry.</div>
<form wire:submit="submit"> <form wire:submit="submit">
@ -6,6 +6,24 @@
<h2>Docker Image</h2> <h2>Docker Image</h2>
<x-forms.button type="submit">Save</x-forms.button> <x-forms.button type="submit">Save</x-forms.button>
</div> </div>
<x-forms.input rows="20" id="dockerImage" placeholder="nginx:latest"></x-forms.textarea> <div class="space-y-4">
<x-forms.input id="imageName" label="Image Name" placeholder="nginx or ghcr.io/user/app"
helper="Enter the Docker image name with optional registry. Examples: nginx, ghcr.io/user/app, localhost:5000/myapp"
required autofocus />
<div class="relative grid grid-cols-1 gap-4 md:grid-cols-2">
<x-forms.input id="imageTag" label="Tag (optional)" placeholder="latest"
helper="Enter a tag like 'latest' or 'v1.2.3'. Leave empty if using SHA256." />
<div
class="absolute left-1/2 top-1/2 transform -translate-x-1/2 -translate-y-1/2 hidden md:flex items-center justify-center z-10">
<div
class="px-2 py-1 bg-white dark:bg-coolgray-100 border border-neutral-300 dark:border-coolgray-300 rounded text-xs font-bold text-neutral-500 dark:text-neutral-400">
OR
</div>
</div>
<x-forms.input id="imageSha256" label="SHA256 Digest (optional)"
placeholder="59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0"
helper="Enter only the 64-character hex digest (without 'sha256:' prefix)" />
</div>
</div>
</form> </form>
</div> </div>

View file

@ -1,5 +1,14 @@
<div> <div>
<div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200"> <div class="flex flex-col gap-4 p-4 bg-white border dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
@if ($fileStorage->is_directory)
This directory is mounted as read-only and cannot be modified from the UI.
@else
This file is mounted as read-only and cannot be modified from the UI.
@endif
</div>
@endif
<div class="flex flex-col justify-center text-sm select-text"> <div class="flex flex-col justify-center text-sm select-text">
<div class="flex gap-2 md:flex-row flex-col"> <div class="flex gap-2 md:flex-row flex-col">
<x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly /> <x-forms.input label="Source Path" :value="$fileStorage->fs_path" readonly />
@ -7,58 +16,75 @@
</div> </div>
</div> </div>
<form wire:submit='submit' class="flex flex-col gap-2"> <form wire:submit='submit' class="flex flex-col gap-2">
@can('update', $resource) @if (!$isReadOnly)
<div class="flex gap-2"> @can('update', $resource)
@if ($fileStorage->is_directory) <div class="flex gap-2">
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?" @if ($fileStorage->is_directory)
buttonTitle="Convert to file" submitAction="convertToFile" :actions="[ <x-modal-confirmation :ignoreWire="false" title="Confirm Directory Conversion to File?"
'All files in this directory will be permanently deleted and an empty file will be created in its place.', buttonTitle="Convert to file" submitAction="convertToFile" :actions="[
]" 'All files in this directory will be permanently deleted and an empty file will be created in its place.',
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
'The selected directory and all its contents will be permanently deleted from the container.',
]"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@else
@if (!$fileStorage->is_binary)
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
'The selected file will be permanently deleted and an empty directory will be created in its place.',
]" ]"
confirmationText="{{ $fs_path }}" confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below" confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to directory" /> shortConfirmationLabel="Filepath" :confirmWithPassword="false" step2ButtonText="Convert to file" />
<x-modal-confirmation :ignoreWire="false" title="Confirm Directory Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$directoryDeletionCheckboxes" :actions="[
'The selected directory and all its contents will be permanently deleted from the container.',
]"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@else
@if (!$fileStorage->is_binary)
<x-modal-confirmation :ignoreWire="false" title="Confirm File Conversion to Directory?"
buttonTitle="Convert to directory" submitAction="convertToDirectory" :actions="[
'The selected file will be permanently deleted and an empty directory will be created in its place.',
]"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" :confirmWithPassword="false"
step2ButtonText="Convert to directory" />
@endif
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from
server</x-forms.button>
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete"
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']"
confirmationText="{{ $fs_path }}"
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below"
shortConfirmationLabel="Filepath" />
@endif @endif
<x-forms.button type="button" wire:click="loadStorageOnServer">Load from server</x-forms.button> </div>
<x-modal-confirmation :ignoreWire="false" title="Confirm File Deletion?" buttonTitle="Delete" @endcan
isErrorButton submitAction="delete" :checkboxes="$fileDeletionCheckboxes" :actions="['The selected file will be permanently deleted from the container.']" @if (!$fileStorage->is_directory)
confirmationText="{{ $fs_path }}" @can('update', $resource)
confirmationLabel="Please confirm the execution of the actions by entering the Filepath below" @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
shortConfirmationLabel="Filepath" /> <div class="w-96">
@endif <x-forms.checkbox instantSave label="Is this based on the Git repository?"
</div> id="fileStorage.is_based_on_git"></x-forms.checkbox>
@endcan </div>
@if (!$fileStorage->is_directory) @endif
@can('update', $resource) <x-forms.textarea
@if (data_get($resource, 'settings.is_preserve_repository_enabled')) label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
<div class="w-96"> rows="20" id="fileStorage.content"
<x-forms.checkbox instantSave label="Is this based on the Git repository?" readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea>
id="fileStorage.is_based_on_git"></x-forms.checkbox> @if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary)
</div> <x-forms.button class="w-full" type="submit">Save</x-forms.button>
@endif @endif
<x-forms.textarea @else
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}" @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
rows="20" id="fileStorage.content" <div class="w-96">
readonly="{{ $fileStorage->is_based_on_git || $fileStorage->is_binary }}"></x-forms.textarea> <x-forms.checkbox disabled label="Is this based on the Git repository?"
@if (!$fileStorage->is_based_on_git && !$fileStorage->is_binary) id="fileStorage.is_based_on_git"></x-forms.checkbox>
<x-forms.button class="w-full" type="submit">Save</x-forms.button> </div>
@endif @endif
@else <x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content" disabled></x-forms.textarea>
@endcan
@endif
@else
{{-- Read-only view --}}
@if (!$fileStorage->is_directory)
@if (data_get($resource, 'settings.is_preserve_repository_enabled')) @if (data_get($resource, 'settings.is_preserve_repository_enabled'))
<div class="w-96"> <div class="w-96">
<x-forms.checkbox disabled label="Is this based on the Git repository?" <x-forms.checkbox disabled label="Is this based on the Git repository?"
@ -68,7 +94,7 @@
<x-forms.textarea <x-forms.textarea
label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}" label="{{ $fileStorage->is_based_on_git ? 'Content (refreshed after a successful deployment)' : 'Content' }}"
rows="20" id="fileStorage.content" disabled></x-forms.textarea> rows="20" id="fileStorage.content" disabled></x-forms.textarea>
@endcan @endif
@endif @endif
</form> </form>
</div> </div>

View file

@ -23,7 +23,8 @@
volumeModalOpen: false, volumeModalOpen: false,
fileModalOpen: false, fileModalOpen: false,
directoryModalOpen: false directoryModalOpen: false
}" @close-storage-modal.window=" }"
@close-storage-modal.window="
if ($event.detail === 'volume') volumeModalOpen = false; if ($event.detail === 'volume') volumeModalOpen = false;
if ($event.detail === 'file') fileModalOpen = false; if ($event.detail === 'file') fileModalOpen = false;
if ($event.detail === 'directory') directoryModalOpen = false; if ($event.detail === 'directory') directoryModalOpen = false;
@ -45,8 +46,7 @@
<div <div
class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300"> class="p-1 mt-1 bg-white border rounded-sm shadow-sm dark:bg-coolgray-200 dark:border-coolgray-300 border-neutral-300">
<div class="flex flex-col gap-1"> <div class="flex flex-col gap-1">
<a class="dropdown-item" <a class="dropdown-item" @click="volumeModalOpen = true; dropdownOpen = false">
@click="volumeModalOpen = true; dropdownOpen = false">
<svg class="size-4" fill="none" stroke="currentColor" <svg class="size-4" fill="none" stroke="currentColor"
viewBox="0 0 24 24"> viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
@ -105,31 +105,41 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</button> </button>
</div> </div>
<div class="relative flex items-center justify-center w-auto" <div class="relative flex items-center justify-center w-auto"
x-init="$watch('volumeModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })"> x-init="$watch('volumeModalOpen', value => {
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitPersistentVolume'> if (value) {
$nextTick(() => {
const input = $el.querySelector('input');
input?.focus();
})
}
})">
<form class="flex flex-col w-full gap-2 rounded-sm"
wire:submit='submitPersistentVolume'>
<div class="flex flex-col"> <div class="flex flex-col">
<div>Docker Volumes mounted to the container.</div> <div>Docker Volumes mounted to the container.</div>
</div> </div>
@if ($isSwarm) @if ($isSwarm)
<div class="text-warning">Swarm Mode detected: You need to set a shared volume <div class="text-warning">Swarm Mode detected: You need to set a shared
(EFS/NFS/etc) on all the worker nodes if you would like to use a persistent volume
(EFS/NFS/etc) on all the worker nodes if you would like to use a
persistent
volumes.</div> volumes.</div>
@endif @endif
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name" <x-forms.input canGate="update" :canResource="$resource" placeholder="pv-name"
id="name" label="Name" required helper="Volume name." /> id="name" label="Name" required helper="Volume name." />
@if ($isSwarm) @if ($isSwarm)
<x-forms.input canGate="update" :canResource="$resource" placeholder="/root" <x-forms.input canGate="update" :canResource="$resource"
id="host_path" label="Source Path" required placeholder="/root" id="host_path" label="Source Path" required
helper="Directory on the host system." /> helper="Directory on the host system." />
@else @else
<x-forms.input canGate="update" :canResource="$resource" placeholder="/root" <x-forms.input canGate="update" :canResource="$resource"
id="host_path" label="Source Path" placeholder="/root" id="host_path" label="Source Path"
helper="Directory on the host system." /> helper="Directory on the host system." />
@endif @endif
<x-forms.input canGate="update" :canResource="$resource" placeholder="/tmp/root" <x-forms.input canGate="update" :canResource="$resource"
id="mount_path" label="Destination Path" required placeholder="/tmp/root" id="mount_path" label="Destination Path"
helper="Directory inside the container." /> required helper="Directory inside the container." />
<x-forms.button canGate="update" :canResource="$resource" type="submit"> <x-forms.button canGate="update" :canResource="$resource" type="submit">
Add Add
</x-forms.button> </x-forms.button>
@ -169,15 +179,24 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</button> </button>
</div> </div>
<div class="relative flex items-center justify-center w-auto" <div class="relative flex items-center justify-center w-auto"
x-init="$watch('fileModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })"> x-init="$watch('fileModalOpen', value => {
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitFileStorage'> if (value) {
$nextTick(() => {
const input = $el.querySelector('input');
input?.focus();
})
}
})">
<form class="flex flex-col w-full gap-2 rounded-sm"
wire:submit='submitFileStorage'>
<div class="flex flex-col"> <div class="flex flex-col">
<div>Actual file mounted from the host system to the container.</div> <div>Actual file mounted from the host system to the container.</div>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource" <x-forms.input canGate="update" :canResource="$resource"
placeholder="/etc/nginx/nginx.conf" id="file_storage_path" placeholder="/etc/nginx/nginx.conf" id="file_storage_path"
label="Destination Path" required helper="File location inside the container" /> label="Destination Path" required
helper="File location inside the container" />
<x-forms.textarea canGate="update" :canResource="$resource" label="Content" <x-forms.textarea canGate="update" :canResource="$resource" label="Content"
id="file_storage_content"></x-forms.textarea> id="file_storage_content"></x-forms.textarea>
<x-forms.button canGate="update" :canResource="$resource" type="submit"> <x-forms.button canGate="update" :canResource="$resource" type="submit">
@ -219,18 +238,27 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
</button> </button>
</div> </div>
<div class="relative flex items-center justify-center w-auto" <div class="relative flex items-center justify-center w-auto"
x-init="$watch('directoryModalOpen', value => { if(value) { $nextTick(() => { const input = $el.querySelector('input'); input?.focus(); }) } })"> x-init="$watch('directoryModalOpen', value => {
<form class="flex flex-col w-full gap-2 rounded-sm" wire:submit='submitFileStorageDirectory'> if (value) {
$nextTick(() => {
const input = $el.querySelector('input');
input?.focus();
})
}
})">
<form class="flex flex-col w-full gap-2 rounded-sm"
wire:submit='submitFileStorageDirectory'>
<div class="flex flex-col"> <div class="flex flex-col">
<div>Directory mounted from the host system to the container.</div> <div>Directory mounted from the host system to the container.</div>
</div> </div>
<div class="flex flex-col gap-2"> <div class="flex flex-col gap-2">
<x-forms.input canGate="update" :canResource="$resource" <x-forms.input canGate="update" :canResource="$resource"
placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx" placeholder="{{ application_configuration_dir() }}/{{ $resource->uuid }}/etc/nginx"
id="file_storage_directory_source" label="Source Directory" required id="file_storage_directory_source" label="Source Directory"
helper="Directory on the host system." /> required helper="Directory on the host system." />
<x-forms.input canGate="update" :canResource="$resource" placeholder="/etc/nginx" <x-forms.input canGate="update" :canResource="$resource"
id="file_storage_directory_destination" label="Destination Directory" required placeholder="/etc/nginx" id="file_storage_directory_destination"
label="Destination Directory" required
helper="Directory inside the container." /> helper="Directory inside the container." />
<x-forms.button canGate="update" :canResource="$resource" type="submit"> <x-forms.button canGate="update" :canResource="$resource" type="submit">
Add Add
@ -270,19 +298,22 @@ class="absolute top-0 right-0 flex items-center justify-center w-8 h-8 mt-5 mr-5
{{-- Tabs Navigation --}} {{-- Tabs Navigation --}}
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200"> <div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
<button @click="activeTab = 'volumes'" <button @click="activeTab = 'volumes'"
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'" :class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasVolumes) disabled @endif @if (!$hasVolumes) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100"> class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Volumes ({{ $this->volumeCount }}) Volumes ({{ $this->volumeCount }})
</button> </button>
<button @click="activeTab = 'files'" <button @click="activeTab = 'files'"
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'" :class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasFiles) disabled @endif @if (!$hasFiles) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100"> class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Files ({{ $this->fileCount }}) Files ({{ $this->fileCount }})
</button> </button>
<button @click="activeTab = 'directories'" <button @click="activeTab = 'directories'"
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' : 'border-b-2 border-transparent'" :class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasDirectories) disabled @endif @if (!$hasDirectories) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100"> class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Directories ({{ $this->directoryCount }}) Directories ({{ $this->directoryCount }})
@ -333,19 +364,96 @@ class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark
</div> </div>
@endif @endif
@else @else
@if ($resource->persistentStorages()->get()->count() > 0) <div class="flex flex-col gap-4 py-2">
<h3>{{ Str::headline($resource->name) }} </h3> <div>
@endif <div class="flex items-center gap-2">
@if ($resource->persistentStorages()->get()->count() > 0) <h2>{{ Str::headline($resource->name) }}</h2>
<livewire:project.shared.storages.all :resource="$resource" /> </div>
@endif
@if ($fileStorage->count() > 0)
<div class="flex flex-col gap-4">
@foreach ($fileStorage->sort() as $fileStorage)
<livewire:project.service.file-storage :fileStorage="$fileStorage"
wire:key="resource-{{ $fileStorage->uuid }}" />
@endforeach
</div> </div>
@endif
@if ($resource->persistentStorages()->get()->count() === 0 && $fileStorage->count() == 0)
<div>No storage found.</div>
@endif
@php
$hasVolumes = $this->volumeCount > 0;
$hasFiles = $this->fileCount > 0;
$hasDirectories = $this->directoryCount > 0;
$defaultTab = $hasVolumes ? 'volumes' : ($hasFiles ? 'files' : 'directories');
@endphp
@if ($hasVolumes || $hasFiles || $hasDirectories)
<div x-data="{
activeTab: '{{ $defaultTab }}'
}">
{{-- Tabs Navigation --}}
<div class="flex gap-2 border-b dark:border-coolgray-300 border-neutral-200">
<button @click="activeTab = 'volumes'"
:class="activeTab === 'volumes' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasVolumes) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasVolumes ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Volumes ({{ $this->volumeCount }})
</button>
<button @click="activeTab = 'files'"
:class="activeTab === 'files' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasFiles) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasFiles ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Files ({{ $this->fileCount }})
</button>
<button @click="activeTab = 'directories'"
:class="activeTab === 'directories' ? 'border-b-2 dark:border-white border-black' :
'border-b-2 border-transparent'"
@if (!$hasDirectories) disabled @endif
class="px-4 py-2 -mb-px font-medium transition-colors {{ $hasDirectories ? 'dark:text-neutral-400 dark:hover:text-white text-neutral-600 hover:text-black cursor-pointer' : 'opacity-50 cursor-not-allowed' }} focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-coollabs dark:focus-visible:ring-warning focus-visible:ring-offset-2 dark:focus-visible:ring-offset-coolgray-100">
Directories ({{ $this->directoryCount }})
</button>
</div>
{{-- Tab Content --}}
<div class="pt-4">
{{-- Volumes Tab --}}
<div x-show="activeTab === 'volumes'" class="flex flex-col gap-4">
@if ($hasVolumes)
<livewire:project.shared.storages.all :resource="$resource" />
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No volumes configured.
</div>
@endif
</div>
{{-- Files Tab --}}
<div x-show="activeTab === 'files'" class="flex flex-col gap-4">
@if ($hasFiles)
@foreach ($this->files as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="file-{{ $fs->id }}" />
@endforeach
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No file mounts configured.
</div>
@endif
</div>
{{-- Directories Tab --}}
<div x-show="activeTab === 'directories'" class="flex flex-col gap-4">
@if ($hasDirectories)
@foreach ($this->directories as $fs)
<livewire:project.service.file-storage :fileStorage="$fs"
wire:key="directory-{{ $fs->id }}" />
@endforeach
@else
<div class="text-center py-8 dark:text-neutral-500 text-neutral-400">
No directory mounts configured.
</div>
@endif
</div>
</div>
</div>
@endif
</div>
@endif @endif
</div> </div>

View file

@ -1,6 +1,9 @@
<div> <div>
<form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200"> <form wire:submit='submit' class="flex flex-col items-center gap-4 p-4 bg-white border lg:items-start dark:bg-base dark:border-coolgray-300 border-neutral-200">
@if ($isReadOnly) @if ($isReadOnly)
<div class="w-full p-2 text-sm rounded bg-warning/10 text-warning">
This volume is mounted as read-only and cannot be modified from the UI.
</div>
@if ($isFirst) @if ($isFirst)
<div class="flex gap-2 items-end w-full md:flex-row flex-col"> <div class="flex gap-2 items-end w-full md:flex-row flex-col">
@if ( @if (

View file

@ -0,0 +1,88 @@
# documentation: https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker
# slogan: Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack
# tags: elastic,kibana,elasticsearch,search,visualization,logging,monitoring,observability,analytics,stack,devops
# logo: svgs/elasticsearch.svg
# port: 5601
services:
elasticsearch:
image: 'elastic/elasticsearch:9.1.2'
container_name: elasticsearch
restart: unless-stopped
environment:
- ELASTIC_PASSWORD=${SERVICE_PASSWORD_ELASTICSEARCH}
- 'ES_JAVA_OPTS=-Xms512m -Xmx512m'
- discovery.type=single-node
- bootstrap.memory_lock=true
- xpack.security.enabled=true
- xpack.security.http.ssl.enabled=false
- xpack.security.transport.ssl.enabled=false
volumes:
- '/etc/localtime:/etc/localtime:ro'
- 'elasticsearch-data:/usr/share/elasticsearch/data'
healthcheck:
test:
- CMD-SHELL
- 'curl --user elastic:${SERVICE_PASSWORD_ELASTICSEARCH} --silent --fail http://localhost:9200/_cluster/health || exit 1'
interval: 10s
timeout: 10s
retries: 24
kibana:
image: 'kibana:9.1.2'
container_name: kibana
restart: unless-stopped
environment:
- SERVICE_URL_KIBANA_5601
- 'SERVER_NAME=${SERVICE_URL_KIBANA}'
- 'SERVER_PUBLICBASEURL=${SERVICE_URL_KIBANA}'
- 'ELASTICSEARCH_HOSTS=http://elasticsearch:9200'
- 'ELASTICSEARCH_USERNAME=kibana_system'
- 'ELASTICSEARCH_PASSWORD=${SERVICE_PASSWORD_KIBANA}'
- 'XPACK_SECURITY_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKSECURITY}'
- 'XPACK_REPORTING_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKREPORTING}'
- 'XPACK_ENCRYPTEDSAVEDOBJECTS_ENCRYPTIONKEY=${SERVICE_PASSWORD_XPACKENCRYPTEDSAVEDOBJECTS}'
- 'TELEMETRY_OPTIN=${TELEMETRY_OPTIN:-false}'
volumes:
- '/etc/localtime:/etc/localtime:ro'
- 'kibana-data:/usr/share/kibana/data'
depends_on:
setup:
condition: service_completed_successfully
healthcheck:
test:
- CMD-SHELL
- "curl -s http://localhost:5601/api/status | grep -q '\"level\":\"available\"' || exit 1"
interval: 10s
timeout: 10s
retries: 120
setup:
image: 'elastic/elasticsearch:9.1.2'
container_name: kibana-setup
depends_on:
elasticsearch:
condition: service_healthy
exclude_from_hc: true
environment:
- 'ELASTIC_PASSWORD=${SERVICE_PASSWORD_ELASTICSEARCH}'
- 'KIBANA_PASSWORD=${SERVICE_PASSWORD_KIBANA}'
entrypoint:
- sh
- '-c'
- |
echo "Setting up Kibana user password..."
until curl -s -u "elastic:${ELASTIC_PASSWORD}" http://elasticsearch:9200/_cluster/health | grep -q '"status":"green\|yellow"'; do
echo "Waiting for Elasticsearch..."
sleep 2
done
echo "Setting password for kibana_system user..."
curl -s -X POST -u "elastic:${ELASTIC_PASSWORD}" \
-H "Content-Type: application/json" \
http://elasticsearch:9200/_security/user/kibana_system/_password \
-d "{\"password\":\"${KIBANA_PASSWORD}\"}" || exit 1
echo "Kibana setup completed successfully"
restart: 'no'

View file

@ -0,0 +1,113 @@
# documentation: https://help.ente.io/self-hosting/installation/compose
# slogan: Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.
# category: media
# tags: photos,gallery,backup,encryption,privacy,self-hosted,google-photos,alternative
# logo: svgs/ente-photos.svg
# port: 8080
services:
museum:
image: ghcr.io/ente-io/server:latest
environment:
- SERVICE_URL_MUSEUM_8080
- ENTE_HTTP_USE_TLS=${ENTE_HTTP_USE_TLS:-false}
- ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002}
- ENTE_APPS_CAST=${SERVICE_URL_WEB_3004}
- ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001}
- ENTE_DB_HOST=${ENTE_DB_HOST:-postgres}
- ENTE_DB_PORT=${ENTE_DB_PORT:-5432}
- ENTE_DB_NAME=${ENTE_DB_NAME:-ente_db}
- ENTE_DB_USER=${SERVICE_USER_POSTGRES:-pguser}
- ENTE_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- ENTE_KEY_ENCRYPTION=${SERVICE_REALBASE64_ENCRYPTION}
- ENTE_KEY_HASH=${SERVICE_REALBASE64_64_HASH}
- ENTE_JWT_SECRET=${SERVICE_REALBASE64_JWT}
- ENTE_INTERNAL_ADMIN=${ENTE_INTERNAL_ADMIN:-1580559962386438}
- ENTE_INTERNAL_DISABLE_REGISTRATION=${ENTE_INTERNAL_DISABLE_REGISTRATION:-false}
# S3/MinIO configuration
- S3_ARE_LOCAL_BUCKETS=true
- S3_USE_PATH_STYLE_URLS=true
- S3_B2_EU_CEN_KEY=${SERVICE_USER_MINIO}
- S3_B2_EU_CEN_SECRET=${SERVICE_PASSWORD_MINIO}
- S3_B2_EU_CEN_ENDPOINT=${SERVICE_URL_MINIO_3200}
- S3_B2_EU_CEN_REGION=eu-central-2
- S3_B2_EU_CEN_BUCKET=b2-eu-cen
volumes:
- museum-data:/data
- museum-config:/config
depends_on:
postgres:
condition: service_healthy
minio:
condition: service_started
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/ping"]
interval: 5s
timeout: 5s
retries: 10
web:
image: ghcr.io/ente-io/web
environment:
- SERVICE_URL_WEB_3000
- ENTE_API_ORIGIN=${SERVICE_URL_MUSEUM}
- ENTE_ALBUMS_ORIGIN=${SERVICE_URL_WEB_3002}
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3000"]
interval: 5s
timeout: 5s
retries: 10
postgres:
image: postgres:15-alpine
environment:
- POSTGRES_USER=${SERVICE_USER_POSTGRES}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- POSTGRES_DB=${POSTGRES_DB:-ente_db}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
minio:
image: quay.io/minio/minio:latest
environment:
- SERVICE_URL_MINIO_9000
- MINIO_ROOT_USER=${SERVICE_USER_MINIO}
- MINIO_ROOT_PASSWORD=${SERVICE_PASSWORD_MINIO}
command: server /data --address ":9000" --console-address ":9001"
volumes:
- minio-data:/data
healthcheck:
test: ["CMD", "mc", "ready", "local"]
interval: 5s
timeout: 20s
retries: 10
minio-init:
image: minio/mc:latest
exclude_from_hc: true
restart: no
depends_on:
minio:
condition: service_healthy
environment:
- MINIO_ROOT_USER=${SERVICE_USER_MINIO}
- MINIO_ROOT_PASSWORD=${SERVICE_PASSWORD_MINIO}
entrypoint: >
/bin/sh -c "
mc alias set minio http://minio:9000 $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD};
mc mb minio/b2-eu-cen --ignore-existing;
mc mb minio/wasabi-eu-central-2-v3 --ignore-existing;
mc mb minio/scw-eu-fr-v3 --ignore-existing;
echo 'MinIO buckets created successfully';
"

View file

@ -0,0 +1,80 @@
# documentation: https://help.ente.io/self-hosting/installation/compose
# slogan: Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.
# category: media
# tags: photos,gallery,backup,encryption,privacy,self-hosted,google-photos,alternative
# logo: svgs/ente-photos.svg
# port: 8080
services:
museum:
image: ghcr.io/ente-io/server:latest
environment:
- SERVICE_URL_MUSEUM_8080
- ENTE_HTTP_USE_TLS=${ENTE_HTTP_USE_TLS:-false}
- ENTE_APPS_PUBLIC_ALBUMS=${SERVICE_URL_WEB_3002}
- ENTE_APPS_CAST=${SERVICE_URL_WEB_3004}
- ENTE_APPS_ACCOUNTS=${SERVICE_URL_WEB_3001}
- ENTE_DB_HOST=${ENTE_DB_HOST:-postgres}
- ENTE_DB_PORT=${ENTE_DB_PORT:-5432}
- ENTE_DB_NAME=${ENTE_DB_NAME:-ente_db}
- ENTE_DB_USER=${SERVICE_USER_POSTGRES:-pguser}
- ENTE_DB_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- ENTE_KEY_ENCRYPTION=${SERVICE_REALBASE64_ENCRYPTION}
- ENTE_KEY_HASH=${SERVICE_REALBASE64_64_HASH}
- ENTE_JWT_SECRET=${SERVICE_REALBASE64_JWT}
- ENTE_INTERNAL_ADMIN=${ENTE_INTERNAL_ADMIN:-1580559962386438}
- ENTE_INTERNAL_DISABLE_REGISTRATION=${ENTE_INTERNAL_DISABLE_REGISTRATION:-false}
- ENTE_S3_B2_EU_CEN_ARE_LOCAL_BUCKETS=${PRIMARY_STORAGE_ARE_LOCAL_BUCKETS:-false}
- ENTE_S3_B2_EU_CEN_USE_PATH_STYLE_URLS=${PRIMARY_STORAGE_USE_PATH_STYLE_URLS:-true}
- ENTE_S3_B2_EU_CEN_KEY=${S3_STORAGE_KEY:?}
- ENTE_S3_B2_EU_CEN_SECRET=${S3_STORAGE_SECRET:?}
- ENTE_S3_B2_EU_CEN_ENDPOINT=${S3_STORAGE_ENDPOINT:?}
- ENTE_S3_B2_EU_CEN_REGION=${S3_STORAGE_REGION:-us-east-1}
- ENTE_S3_B2_EU_CEN_BUCKET=${S3_STORAGE_BUCKET:?}
depends_on:
postgres:
condition: service_healthy
volumes:
- museum-data:/data
- museum-config:/config
healthcheck:
test: ["CMD", "wget", "-qO-", "http://127.0.0.1:8080/ping"]
interval: 5s
timeout: 5s
retries: 10
web:
image: ghcr.io/ente-io/web
environment:
- SERVICE_URL_WEB_3000
- ENTE_API_ORIGIN=${SERVICE_URL_MUSEUM}
- ENTE_ALBUMS_ORIGIN=${SERVICE_URL_WEB_3002}
healthcheck:
test: ["CMD", "curl", "--fail", "http://127.0.0.1:3000"]
interval: 5s
timeout: 5s
retries: 10
postgres:
image: postgres:15
environment:
- POSTGRES_USER=${SERVICE_USER_POSTGRES:-pguser}
- POSTGRES_PASSWORD=${SERVICE_PASSWORD_POSTGRES}
- POSTGRES_DB=${SERVICE_DB_NAME:-ente_db}
volumes:
- postgres-data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10

View file

@ -899,6 +899,28 @@
"minversion": "0.0.0", "minversion": "0.0.0",
"port": "80" "port": "80"
}, },
"elasticsearch-with-kibana": {
"documentation": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker?utm_source=coolify.io",
"slogan": "Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack",
"compose": "c2VydmljZXM6CiAgZWxhc3RpY3NlYXJjaDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGVsYXN0aWNzZWFyY2gKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0VTX0pBVkFfT1BUUz0tWG1zNTEybSAtWG14NTEybScKICAgICAgLSBkaXNjb3ZlcnkudHlwZT1zaW5nbGUtbm9kZQogICAgICAtIGJvb3RzdHJhcC5tZW1vcnlfbG9jaz10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuZW5hYmxlZD10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuaHR0cC5zc2wuZW5hYmxlZD1mYWxzZQogICAgICAtIHhwYWNrLnNlY3VyaXR5LnRyYW5zcG9ydC5zc2wuZW5hYmxlZD1mYWxzZQogICAgdm9sdW1lczoKICAgICAgLSAnL2V0Yy9sb2NhbHRpbWU6L2V0Yy9sb2NhbHRpbWU6cm8nCiAgICAgIC0gJ2VsYXN0aWNzZWFyY2gtZGF0YTovdXNyL3NoYXJlL2VsYXN0aWNzZWFyY2gvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtLXVzZXIgZWxhc3RpYzoke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0gLS1zaWxlbnQgLS1mYWlsIGh0dHA6Ly9sb2NhbGhvc3Q6OTIwMC9fY2x1c3Rlci9oZWFsdGggfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyNAogIGtpYmFuYToKICAgIGltYWdlOiAna2liYW5hOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYQogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX0tJQkFOQV81NjAxCiAgICAgIC0gJ1NFUlZFUl9OQU1FPSR7U0VSVklDRV9VUkxfS0lCQU5BfScKICAgICAgLSAnU0VSVkVSX1BVQkxJQ0JBU0VVUkw9JHtTRVJWSUNFX1VSTF9LSUJBTkF9JwogICAgICAtICdFTEFTVElDU0VBUkNIX0hPU1RTPWh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAnCiAgICAgIC0gRUxBU1RJQ1NFQVJDSF9VU0VSTkFNRT1raWJhbmFfc3lzdGVtCiAgICAgIC0gJ0VMQVNUSUNTRUFSQ0hfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tJQkFOQX0nCiAgICAgIC0gJ1hQQUNLX1NFQ1VSSVRZX0VOQ1JZUFRJT05LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1hQQUNLU0VDVVJJVFl9JwogICAgICAtICdYUEFDS19SRVBPUlRJTkdfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tSRVBPUlRJTkd9JwogICAgICAtICdYUEFDS19FTkNSWVBURURTQVZFRE9CSkVDVFNfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tFTkNSWVBURURTQVZFRE9CSkVDVFN9JwogICAgICAtICdURUxFTUVUUllfT1BUSU49JHtURUxFTUVUUllfT1BUSU46LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy9ldGMvbG9jYWx0aW1lOi9ldGMvbG9jYWx0aW1lOnJvJwogICAgICAtICdraWJhbmEtZGF0YTovdXNyL3NoYXJlL2tpYmFuYS9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgc2V0dXA6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtcyBodHRwOi8vbG9jYWxob3N0OjU2MDEvYXBpL3N0YXR1cyB8IGdyZXAgLXEgJycibGV2ZWwiOiJhdmFpbGFibGUiJycgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMjAKICBzZXR1cDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYS1zZXR1cAogICAgZGVwZW5kc19vbjoKICAgICAgZWxhc3RpY3NlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0tJQkFOQV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0lCQU5BfScKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gImVjaG8gXCJTZXR0aW5nIHVwIEtpYmFuYSB1c2VyIHBhc3N3b3JkLi4uXCJcblxudW50aWwgY3VybCAtcyAtdSBcImVsYXN0aWM6JHtFTEFTVElDX1BBU1NXT1JEfVwiIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX2NsdXN0ZXIvaGVhbHRoIHwgZ3JlcCAtcSAnXCJzdGF0dXNcIjpcImdyZWVuXFx8eWVsbG93XCInOyBkb1xuICBlY2hvIFwiV2FpdGluZyBmb3IgRWxhc3RpY3NlYXJjaC4uLlwiXG4gIHNsZWVwIDJcbmRvbmVcblxuZWNobyBcIlNldHRpbmcgcGFzc3dvcmQgZm9yIGtpYmFuYV9zeXN0ZW0gdXNlci4uLlwiXG5jdXJsIC1zIC1YIFBPU1QgLXUgXCJlbGFzdGljOiR7RUxBU1RJQ19QQVNTV09SRH1cIiBcXFxuICAtSCBcIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvblwiIFxcXG4gIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX3NlY3VyaXR5L3VzZXIva2liYW5hX3N5c3RlbS9fcGFzc3dvcmQgXFxcbiAgLWQgXCJ7XFxcInBhc3N3b3JkXFxcIjpcXFwiJHtLSUJBTkFfUEFTU1dPUkR9XFxcIn1cIiB8fCBleGl0IDFcblxuZWNobyBcIktpYmFuYSBzZXR1cCBjb21wbGV0ZWQgc3VjY2Vzc2Z1bGx5XCIiCiAgICByZXN0YXJ0OiAnbm8nCg==",
"tags": [
"elastic",
"kibana",
"elasticsearch",
"search",
"visualization",
"logging",
"monitoring",
"observability",
"analytics",
"stack",
"devops"
],
"category": null,
"logo": "svgs/elasticsearch.svg",
"minversion": "0.0.0",
"port": "5601"
},
"elasticsearch": { "elasticsearch": {
"documentation": "https://www.elastic.co/products/elasticsearch?utm_source=coolify.io", "documentation": "https://www.elastic.co/products/elasticsearch?utm_source=coolify.io",
"slogan": "Elasticsearch is free and Open Source, Distributed, RESTful Search Engine.", "slogan": "Elasticsearch is free and Open Source, Distributed, RESTful Search Engine.",
@ -948,6 +970,44 @@
"minversion": "0.0.0", "minversion": "0.0.0",
"port": "6555" "port": "6555"
}, },
"ente-photos-with-s3": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSBTM19BUkVfTE9DQUxfQlVDS0VUUz10cnVlCiAgICAgIC0gUzNfVVNFX1BBVEhfU1RZTEVfVVJMUz10cnVlCiAgICAgIC0gJ1MzX0IyX0VVX0NFTl9LRVk9JHtTRVJWSUNFX1VTRVJfTUlOSU99JwogICAgICAtICdTM19CMl9FVV9DRU5fU0VDUkVUPSR7U0VSVklDRV9QQVNTV09SRF9NSU5JT30nCiAgICAgIC0gJ1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1NFUlZJQ0VfVVJMX01JTklPXzMyMDB9JwogICAgICAtIFMzX0IyX0VVX0NFTl9SRUdJT049ZXUtY2VudHJhbC0yCiAgICAgIC0gUzNfQjJfRVVfQ0VOX0JVQ0tFVD1iMi1ldS1jZW4KICAgIHZvbHVtZXM6CiAgICAgIC0gJ211c2V1bS1kYXRhOi9kYXRhJwogICAgICAtICdtdXNldW0tY29uZmlnOi9jb25maWcnCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgICBtaW5pbzoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2Vfc3RhcnRlZAogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUtYWxwaW5lJwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfUE9TVEdSRVN9JwogICAgICAtICdQT1NUR1JFU19EQj0ke1BPU1RHUkVTX0RCOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIG1pbmlvOgogICAgaW1hZ2U6ICdxdWF5LmlvL21pbmlvL21pbmlvOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01JTklPXzkwMDAKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWFkZHJlc3MgIjo5MDAwIiAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpsYXRlc3QnCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIHJlc3RhcnQ6ICdubycKICAgIGRlcGVuZHNfb246CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZW50cnlwb2ludDogIi9iaW4vc2ggLWMgXCIgbWMgYWxpYXMgc2V0IG1pbmlvIGh0dHA6Ly9taW5pbzo5MDAwICQke01JTklPX1JPT1RfVVNFUn0gJCR7TUlOSU9fUk9PVF9QQVNTV09SRH07IG1jIG1iIG1pbmlvL2IyLWV1LWNlbiAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vd2FzYWJpLWV1LWNlbnRyYWwtMi12MyAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vc2N3LWV1LWZyLXYzIC0taWdub3JlLWV4aXN0aW5nOyBlY2hvICdNaW5JTyBidWNrZXRzIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5JzsgXCJcbiIK",
"tags": [
"photos",
"gallery",
"backup",
"encryption",
"privacy",
"self-hosted",
"google-photos",
"alternative"
],
"category": "media",
"logo": "svgs/ente-photos.svg",
"minversion": "0.0.0",
"port": "8080"
},
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfVVJMX01VU0VVTV84MDgwCiAgICAgIC0gJ0VOVEVfSFRUUF9VU0VfVExTPSR7RU5URV9IVFRQX1VTRV9UTFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9BUFBTX1BVQkxJQ19BTEJVTVM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMn0nCiAgICAgIC0gJ0VOVEVfQVBQU19DQVNUPSR7U0VSVklDRV9VUkxfV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX1VSTF9XRUJfMzAwMX0nCiAgICAgIC0gJ0VOVEVfREJfSE9TVD0ke0VOVEVfREJfSE9TVDotcG9zdGdyZXN9JwogICAgICAtICdFTlRFX0RCX1BPUlQ9JHtFTlRFX0RCX1BPUlQ6LTU0MzJ9JwogICAgICAtICdFTlRFX0RCX05BTUU9JHtFTlRFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgICAtICdFTlRFX0RCX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ0VOVEVfREJfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnRU5URV9LRVlfRU5DUllQVElPTj0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9FTkNSWVBUSU9OfScKICAgICAgLSAnRU5URV9LRVlfSEFTSD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF82NF9IQVNIfScKICAgICAgLSAnRU5URV9KV1RfU0VDUkVUPSR7U0VSVklDRV9SRUFMQkFTRTY0X0pXVH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfQURNSU49JHtFTlRFX0lOVEVSTkFMX0FETUlOOi0xNTgwNTU5OTYyMzg2NDM4fScKICAgICAgLSAnRU5URV9JTlRFUk5BTF9ESVNBQkxFX1JFR0lTVFJBVElPTj0ke0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT046LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQVJFX0xPQ0FMX0JVQ0tFVFM9JHtQUklNQVJZX1NUT1JBR0VfQVJFX0xPQ0FMX0JVQ0tFVFM6LWZhbHNlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fVVNFX1BBVEhfU1RZTEVfVVJMUz0ke1BSSU1BUllfU1RPUkFHRV9VU0VfUEFUSF9TVFlMRV9VUkxTOi10cnVlfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fS0VZPSR7UzNfU1RPUkFHRV9LRVk6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1MzX1NUT1JBR0VfU0VDUkVUOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9FTkRQT0lOVD0ke1MzX1NUT1JBR0VfRU5EUE9JTlQ6P30nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1JFR0lPTj0ke1MzX1NUT1JBR0VfUkVHSU9OOi11cy1lYXN0LTF9JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9CVUNLRVQ9JHtTM19TVE9SQUdFX0JVQ0tFVDo/fScKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICB2b2x1bWVzOgogICAgICAtICdtdXNldW0tZGF0YTovZGF0YScKICAgICAgLSAnbXVzZXVtLWNvbmZpZzovY29uZmlnJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIHdnZXQKICAgICAgICAtICctcU8tJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6ODA4MC9waW5nJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICB3ZWI6CiAgICBpbWFnZTogZ2hjci5pby9lbnRlLWlvL3dlYgogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9VUkxfV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9VUkxfTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9VUkxfV0VCXzMwMDJ9JwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQKICAgICAgICAtIGN1cmwKICAgICAgICAtICctLWZhaWwnCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTozMDAwJwogICAgICBpbnRlcnZhbDogNXMKICAgICAgdGltZW91dDogNXMKICAgICAgcmV0cmllczogMTAKICBwb3N0Z3JlczoKICAgIGltYWdlOiAncG9zdGdyZXM6MTUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFUzotcGd1c2VyfScKICAgICAgLSAnUE9TVEdSRVNfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX1BPU1RHUkVTfScKICAgICAgLSAnUE9TVEdSRVNfREI9JHtTRVJWSUNFX0RCX05BTUU6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCg==",
"tags": [
"photos",
"gallery",
"backup",
"encryption",
"privacy",
"self-hosted",
"google-photos",
"alternative"
],
"category": "media",
"logo": "svgs/ente-photos.svg",
"minversion": "0.0.0",
"port": "8080"
},
"evolution-api": { "evolution-api": {
"documentation": "https://doc.evolution-api.com/v1/pt/get-started/introduction?utm_source=coolify.io", "documentation": "https://doc.evolution-api.com/v1/pt/get-started/introduction?utm_source=coolify.io",
"slogan": "Evolution API Installation with Postgres and Redis", "slogan": "Evolution API Installation with Postgres and Redis",

View file

@ -899,6 +899,28 @@
"minversion": "0.0.0", "minversion": "0.0.0",
"port": "80" "port": "80"
}, },
"elasticsearch-with-kibana": {
"documentation": "https://www.elastic.co/docs/deploy-manage/deploy/self-managed/install-kibana-with-docker?utm_source=coolify.io",
"slogan": "Elastic + Kibana is a Free and Open Source Search, Monitoring, and Visualization Stack",
"compose": "c2VydmljZXM6CiAgZWxhc3RpY3NlYXJjaDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGVsYXN0aWNzZWFyY2gKICAgIHJlc3RhcnQ6IHVubGVzcy1zdG9wcGVkCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0VTX0pBVkFfT1BUUz0tWG1zNTEybSAtWG14NTEybScKICAgICAgLSBkaXNjb3ZlcnkudHlwZT1zaW5nbGUtbm9kZQogICAgICAtIGJvb3RzdHJhcC5tZW1vcnlfbG9jaz10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuZW5hYmxlZD10cnVlCiAgICAgIC0geHBhY2suc2VjdXJpdHkuaHR0cC5zc2wuZW5hYmxlZD1mYWxzZQogICAgICAtIHhwYWNrLnNlY3VyaXR5LnRyYW5zcG9ydC5zc2wuZW5hYmxlZD1mYWxzZQogICAgdm9sdW1lczoKICAgICAgLSAnL2V0Yy9sb2NhbHRpbWU6L2V0Yy9sb2NhbHRpbWU6cm8nCiAgICAgIC0gJ2VsYXN0aWNzZWFyY2gtZGF0YTovdXNyL3NoYXJlL2VsYXN0aWNzZWFyY2gvZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtLXVzZXIgZWxhc3RpYzoke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0gLS1zaWxlbnQgLS1mYWlsIGh0dHA6Ly9sb2NhbGhvc3Q6OTIwMC9fY2x1c3Rlci9oZWFsdGggfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAyNAogIGtpYmFuYToKICAgIGltYWdlOiAna2liYW5hOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYQogICAgcmVzdGFydDogdW5sZXNzLXN0b3BwZWQKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9LSUJBTkFfNTYwMQogICAgICAtICdTRVJWRVJfTkFNRT0ke1NFUlZJQ0VfRlFETl9LSUJBTkF9JwogICAgICAtICdTRVJWRVJfUFVCTElDQkFTRVVSTD0ke1NFUlZJQ0VfRlFETl9LSUJBTkF9JwogICAgICAtICdFTEFTVElDU0VBUkNIX0hPU1RTPWh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAnCiAgICAgIC0gRUxBU1RJQ1NFQVJDSF9VU0VSTkFNRT1raWJhbmFfc3lzdGVtCiAgICAgIC0gJ0VMQVNUSUNTRUFSQ0hfUEFTU1dPUkQ9JHtTRVJWSUNFX1BBU1NXT1JEX0tJQkFOQX0nCiAgICAgIC0gJ1hQQUNLX1NFQ1VSSVRZX0VOQ1JZUFRJT05LRVk9JHtTRVJWSUNFX1BBU1NXT1JEX1hQQUNLU0VDVVJJVFl9JwogICAgICAtICdYUEFDS19SRVBPUlRJTkdfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tSRVBPUlRJTkd9JwogICAgICAtICdYUEFDS19FTkNSWVBURURTQVZFRE9CSkVDVFNfRU5DUllQVElPTktFWT0ke1NFUlZJQ0VfUEFTU1dPUkRfWFBBQ0tFTkNSWVBURURTQVZFRE9CSkVDVFN9JwogICAgICAtICdURUxFTUVUUllfT1BUSU49JHtURUxFTUVUUllfT1BUSU46LWZhbHNlfScKICAgIHZvbHVtZXM6CiAgICAgIC0gJy9ldGMvbG9jYWx0aW1lOi9ldGMvbG9jYWx0aW1lOnJvJwogICAgICAtICdraWJhbmEtZGF0YTovdXNyL3NoYXJlL2tpYmFuYS9kYXRhJwogICAgZGVwZW5kc19vbjoKICAgICAgc2V0dXA6CiAgICAgICAgY29uZGl0aW9uOiBzZXJ2aWNlX2NvbXBsZXRlZF9zdWNjZXNzZnVsbHkKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ELVNIRUxMCiAgICAgICAgLSAnY3VybCAtcyBodHRwOi8vbG9jYWxob3N0OjU2MDEvYXBpL3N0YXR1cyB8IGdyZXAgLXEgJycibGV2ZWwiOiJhdmFpbGFibGUiJycgfHwgZXhpdCAxJwogICAgICBpbnRlcnZhbDogMTBzCiAgICAgIHRpbWVvdXQ6IDEwcwogICAgICByZXRyaWVzOiAxMjAKICBzZXR1cDoKICAgIGltYWdlOiAnZWxhc3RpYy9lbGFzdGljc2VhcmNoOjkuMS4yJwogICAgY29udGFpbmVyX25hbWU6IGtpYmFuYS1zZXR1cAogICAgZGVwZW5kc19vbjoKICAgICAgZWxhc3RpY3NlYXJjaDoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgZXhjbHVkZV9mcm9tX2hjOiB0cnVlCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnRUxBU1RJQ19QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfRUxBU1RJQ1NFQVJDSH0nCiAgICAgIC0gJ0tJQkFOQV9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfS0lCQU5BfScKICAgIGVudHJ5cG9pbnQ6CiAgICAgIC0gc2gKICAgICAgLSAnLWMnCiAgICAgIC0gImVjaG8gXCJTZXR0aW5nIHVwIEtpYmFuYSB1c2VyIHBhc3N3b3JkLi4uXCJcblxudW50aWwgY3VybCAtcyAtdSBcImVsYXN0aWM6JHtFTEFTVElDX1BBU1NXT1JEfVwiIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX2NsdXN0ZXIvaGVhbHRoIHwgZ3JlcCAtcSAnXCJzdGF0dXNcIjpcImdyZWVuXFx8eWVsbG93XCInOyBkb1xuICBlY2hvIFwiV2FpdGluZyBmb3IgRWxhc3RpY3NlYXJjaC4uLlwiXG4gIHNsZWVwIDJcbmRvbmVcblxuZWNobyBcIlNldHRpbmcgcGFzc3dvcmQgZm9yIGtpYmFuYV9zeXN0ZW0gdXNlci4uLlwiXG5jdXJsIC1zIC1YIFBPU1QgLXUgXCJlbGFzdGljOiR7RUxBU1RJQ19QQVNTV09SRH1cIiBcXFxuICAtSCBcIkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvblwiIFxcXG4gIGh0dHA6Ly9lbGFzdGljc2VhcmNoOjkyMDAvX3NlY3VyaXR5L3VzZXIva2liYW5hX3N5c3RlbS9fcGFzc3dvcmQgXFxcbiAgLWQgXCJ7XFxcInBhc3N3b3JkXFxcIjpcXFwiJHtLSUJBTkFfUEFTU1dPUkR9XFxcIn1cIiB8fCBleGl0IDFcblxuZWNobyBcIktpYmFuYSBzZXR1cCBjb21wbGV0ZWQgc3VjY2Vzc2Z1bGx5XCIiCiAgICByZXN0YXJ0OiAnbm8nCg==",
"tags": [
"elastic",
"kibana",
"elasticsearch",
"search",
"visualization",
"logging",
"monitoring",
"observability",
"analytics",
"stack",
"devops"
],
"category": null,
"logo": "svgs/elasticsearch.svg",
"minversion": "0.0.0",
"port": "5601"
},
"elasticsearch": { "elasticsearch": {
"documentation": "https://www.elastic.co/products/elasticsearch?utm_source=coolify.io", "documentation": "https://www.elastic.co/products/elasticsearch?utm_source=coolify.io",
"slogan": "Elasticsearch is free and Open Source, Distributed, RESTful Search Engine.", "slogan": "Elasticsearch is free and Open Source, Distributed, RESTful Search Engine.",
@ -948,6 +970,44 @@
"minversion": "0.0.0", "minversion": "0.0.0",
"port": "6555" "port": "6555"
}, },
"ente-photos-with-s3": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gUzNfQVJFX0xPQ0FMX0JVQ0tFVFM9dHJ1ZQogICAgICAtIFMzX1VTRV9QQVRIX1NUWUxFX1VSTFM9dHJ1ZQogICAgICAtICdTM19CMl9FVV9DRU5fS0VZPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnUzNfQjJfRVVfQ0VOX1NFQ1JFVD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgICAtICdTM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTRVJWSUNFX0ZRRE5fTUlOSU9fMzIwMH0nCiAgICAgIC0gUzNfQjJfRVVfQ0VOX1JFR0lPTj1ldS1jZW50cmFsLTIKICAgICAgLSBTM19CMl9FVV9DRU5fQlVDS0VUPWIyLWV1LWNlbgogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGRlcGVuZHNfb246CiAgICAgIHBvc3RncmVzOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9zdGFydGVkCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gd2dldAogICAgICAgIC0gJy1xTy0nCiAgICAgICAgLSAnaHR0cDovLzEyNy4wLjAuMTo4MDgwL3BpbmcnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHdlYjoKICAgIGltYWdlOiBnaGNyLmlvL2VudGUtaW8vd2ViCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSBTRVJWSUNFX0ZRRE5fV0VCXzMwMDAKICAgICAgLSAnRU5URV9BUElfT1JJR0lOPSR7U0VSVklDRV9GUUROX01VU0VVTX0nCiAgICAgIC0gJ0VOVEVfQUxCVU1TX09SSUdJTj0ke1NFUlZJQ0VfRlFETl9XRUJfMzAwMn0nCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRAogICAgICAgIC0gY3VybAogICAgICAgIC0gJy0tZmFpbCcKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjMwMDAnCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAogIHBvc3RncmVzOgogICAgaW1hZ2U6ICdwb3N0Z3JlczoxNS1hbHBpbmUnCiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnUE9TVEdSRVNfVVNFUj0ke1NFUlZJQ0VfVVNFUl9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7UE9TVEdSRVNfREI6LWVudGVfZGJ9JwogICAgdm9sdW1lczoKICAgICAgLSAncG9zdGdyZXMtZGF0YTovdmFyL2xpYi9wb3N0Z3Jlc3FsL2RhdGEnCiAgICBoZWFsdGhjaGVjazoKICAgICAgdGVzdDoKICAgICAgICAtIENNRC1TSEVMTAogICAgICAgIC0gJ3BnX2lzcmVhZHkgLVUgJHtQT1NUR1JFU19VU0VSfSAtZCAke1BPU1RHUkVTX0RCfScKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgbWluaW86CiAgICBpbWFnZTogJ3F1YXkuaW8vbWluaW8vbWluaW86bGF0ZXN0JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gU0VSVklDRV9GUUROX01JTklPXzkwMDAKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgY29tbWFuZDogJ3NlcnZlciAvZGF0YSAtLWFkZHJlc3MgIjo5MDAwIiAtLWNvbnNvbGUtYWRkcmVzcyAiOjkwMDEiJwogICAgdm9sdW1lczoKICAgICAgLSAnbWluaW8tZGF0YTovZGF0YScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBtYwogICAgICAgIC0gcmVhZHkKICAgICAgICAtIGxvY2FsCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiAyMHMKICAgICAgcmV0cmllczogMTAKICBtaW5pby1pbml0OgogICAgaW1hZ2U6ICdtaW5pby9tYzpsYXRlc3QnCiAgICBleGNsdWRlX2Zyb21faGM6IHRydWUKICAgIHJlc3RhcnQ6ICdubycKICAgIGRlcGVuZHNfb246CiAgICAgIG1pbmlvOgogICAgICAgIGNvbmRpdGlvbjogc2VydmljZV9oZWFsdGh5CiAgICBlbnZpcm9ubWVudDoKICAgICAgLSAnTUlOSU9fUk9PVF9VU0VSPSR7U0VSVklDRV9VU0VSX01JTklPfScKICAgICAgLSAnTUlOSU9fUk9PVF9QQVNTV09SRD0ke1NFUlZJQ0VfUEFTU1dPUkRfTUlOSU99JwogICAgZW50cnlwb2ludDogIi9iaW4vc2ggLWMgXCIgbWMgYWxpYXMgc2V0IG1pbmlvIGh0dHA6Ly9taW5pbzo5MDAwICQke01JTklPX1JPT1RfVVNFUn0gJCR7TUlOSU9fUk9PVF9QQVNTV09SRH07IG1jIG1iIG1pbmlvL2IyLWV1LWNlbiAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vd2FzYWJpLWV1LWNlbnRyYWwtMi12MyAtLWlnbm9yZS1leGlzdGluZzsgbWMgbWIgbWluaW8vc2N3LWV1LWZyLXYzIC0taWdub3JlLWV4aXN0aW5nOyBlY2hvICdNaW5JTyBidWNrZXRzIGNyZWF0ZWQgc3VjY2Vzc2Z1bGx5JzsgXCJcbiIK",
"tags": [
"photos",
"gallery",
"backup",
"encryption",
"privacy",
"self-hosted",
"google-photos",
"alternative"
],
"category": "media",
"logo": "svgs/ente-photos.svg",
"minversion": "0.0.0",
"port": "8080"
},
"ente-photos": {
"documentation": "https://help.ente.io/self-hosting/installation/compose?utm_source=coolify.io",
"slogan": "Ente Photos is a fully open source, End to End Encrypted alternative to Google Photos and Apple Photos.",
"compose": "c2VydmljZXM6CiAgbXVzZXVtOgogICAgaW1hZ2U6ICdnaGNyLmlvL2VudGUtaW8vc2VydmVyOmxhdGVzdCcKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9NVVNFVU1fODA4MAogICAgICAtICdFTlRFX0hUVFBfVVNFX1RMUz0ke0VOVEVfSFRUUF9VU0VfVExTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfQVBQU19QVUJMSUNfQUxCVU1TPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgICAgLSAnRU5URV9BUFBTX0NBU1Q9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDR9JwogICAgICAtICdFTlRFX0FQUFNfQUNDT1VOVFM9JHtTRVJWSUNFX0ZRRE5fV0VCXzMwMDF9JwogICAgICAtICdFTlRFX0RCX0hPU1Q9JHtFTlRFX0RCX0hPU1Q6LXBvc3RncmVzfScKICAgICAgLSAnRU5URV9EQl9QT1JUPSR7RU5URV9EQl9QT1JUOi01NDMyfScKICAgICAgLSAnRU5URV9EQl9OQU1FPSR7RU5URV9EQl9OQU1FOi1lbnRlX2RifScKICAgICAgLSAnRU5URV9EQl9VU0VSPSR7U0VSVklDRV9VU0VSX1BPU1RHUkVTOi1wZ3VzZXJ9JwogICAgICAtICdFTlRFX0RCX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ0VOVEVfS0VZX0VOQ1JZUFRJT049JHtTRVJWSUNFX1JFQUxCQVNFNjRfRU5DUllQVElPTn0nCiAgICAgIC0gJ0VOVEVfS0VZX0hBU0g9JHtTRVJWSUNFX1JFQUxCQVNFNjRfNjRfSEFTSH0nCiAgICAgIC0gJ0VOVEVfSldUX1NFQ1JFVD0ke1NFUlZJQ0VfUkVBTEJBU0U2NF9KV1R9JwogICAgICAtICdFTlRFX0lOVEVSTkFMX0FETUlOPSR7RU5URV9JTlRFUk5BTF9BRE1JTjotMTU4MDU1OTk2MjM4NjQzOH0nCiAgICAgIC0gJ0VOVEVfSU5URVJOQUxfRElTQUJMRV9SRUdJU1RSQVRJT049JHtFTlRFX0lOVEVSTkFMX0RJU0FCTEVfUkVHSVNUUkFUSU9OOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0FSRV9MT0NBTF9CVUNLRVRTPSR7UFJJTUFSWV9TVE9SQUdFX0FSRV9MT0NBTF9CVUNLRVRTOi1mYWxzZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX1VTRV9QQVRIX1NUWUxFX1VSTFM9JHtQUklNQVJZX1NUT1JBR0VfVVNFX1BBVEhfU1RZTEVfVVJMUzotdHJ1ZX0nCiAgICAgIC0gJ0VOVEVfUzNfQjJfRVVfQ0VOX0tFWT0ke1MzX1NUT1JBR0VfS0VZOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9TRUNSRVQ9JHtTM19TVE9SQUdFX1NFQ1JFVDo/fScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fRU5EUE9JTlQ9JHtTM19TVE9SQUdFX0VORFBPSU5UOj99JwogICAgICAtICdFTlRFX1MzX0IyX0VVX0NFTl9SRUdJT049JHtTM19TVE9SQUdFX1JFR0lPTjotdXMtZWFzdC0xfScKICAgICAgLSAnRU5URV9TM19CMl9FVV9DRU5fQlVDS0VUPSR7UzNfU1RPUkFHRV9CVUNLRVQ6P30nCiAgICBkZXBlbmRzX29uOgogICAgICBwb3N0Z3JlczoKICAgICAgICBjb25kaXRpb246IHNlcnZpY2VfaGVhbHRoeQogICAgdm9sdW1lczoKICAgICAgLSAnbXVzZXVtLWRhdGE6L2RhdGEnCiAgICAgIC0gJ211c2V1bS1jb25maWc6L2NvbmZpZycKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSB3Z2V0CiAgICAgICAgLSAnLXFPLScKICAgICAgICAtICdodHRwOi8vMTI3LjAuMC4xOjgwODAvcGluZycKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgd2ViOgogICAgaW1hZ2U6IGdoY3IuaW8vZW50ZS1pby93ZWIKICAgIGVudmlyb25tZW50OgogICAgICAtIFNFUlZJQ0VfRlFETl9XRUJfMzAwMAogICAgICAtICdFTlRFX0FQSV9PUklHSU49JHtTRVJWSUNFX0ZRRE5fTVVTRVVNfScKICAgICAgLSAnRU5URV9BTEJVTVNfT1JJR0lOPSR7U0VSVklDRV9GUUROX1dFQl8zMDAyfScKICAgIGhlYWx0aGNoZWNrOgogICAgICB0ZXN0OgogICAgICAgIC0gQ01ECiAgICAgICAgLSBjdXJsCiAgICAgICAgLSAnLS1mYWlsJwogICAgICAgIC0gJ2h0dHA6Ly8xMjcuMC4wLjE6MzAwMCcKICAgICAgaW50ZXJ2YWw6IDVzCiAgICAgIHRpbWVvdXQ6IDVzCiAgICAgIHJldHJpZXM6IDEwCiAgcG9zdGdyZXM6CiAgICBpbWFnZTogJ3Bvc3RncmVzOjE1JwogICAgZW52aXJvbm1lbnQ6CiAgICAgIC0gJ1BPU1RHUkVTX1VTRVI9JHtTRVJWSUNFX1VTRVJfUE9TVEdSRVM6LXBndXNlcn0nCiAgICAgIC0gJ1BPU1RHUkVTX1BBU1NXT1JEPSR7U0VSVklDRV9QQVNTV09SRF9QT1NUR1JFU30nCiAgICAgIC0gJ1BPU1RHUkVTX0RCPSR7U0VSVklDRV9EQl9OQU1FOi1lbnRlX2RifScKICAgIHZvbHVtZXM6CiAgICAgIC0gJ3Bvc3RncmVzLWRhdGE6L3Zhci9saWIvcG9zdGdyZXNxbC9kYXRhJwogICAgaGVhbHRoY2hlY2s6CiAgICAgIHRlc3Q6CiAgICAgICAgLSBDTUQtU0hFTEwKICAgICAgICAtICdwZ19pc3JlYWR5IC1VICR7UE9TVEdSRVNfVVNFUn0gLWQgJHtQT1NUR1JFU19EQn0nCiAgICAgIGludGVydmFsOiA1cwogICAgICB0aW1lb3V0OiA1cwogICAgICByZXRyaWVzOiAxMAo=",
"tags": [
"photos",
"gallery",
"backup",
"encryption",
"privacy",
"self-hosted",
"google-photos",
"alternative"
],
"category": "media",
"logo": "svgs/ente-photos.svg",
"minversion": "0.0.0",
"port": "8080"
},
"evolution-api": { "evolution-api": {
"documentation": "https://doc.evolution-api.com/v1/pt/get-started/introduction?utm_source=coolify.io", "documentation": "https://doc.evolution-api.com/v1/pt/get-started/introduction?utm_source=coolify.io",
"slogan": "Evolution API Installation with Postgres and Redis", "slogan": "Evolution API Installation with Postgres and Redis",

View file

@ -1,94 +1,109 @@
<?php <?php
namespace Tests\Unit;
use App\Services\DockerImageParser; use App\Services\DockerImageParser;
use PHPUnit\Framework\Attributes\Test;
use PHPUnit\Framework\TestCase;
class DockerImageParserTest extends TestCase it('parses regular image with tag', function () {
{ $parser = new DockerImageParser;
private DockerImageParser $parser; $parser->parse('nginx:latest');
protected function setUp(): void expect($parser->getImageName())->toBe('nginx')
{ ->and($parser->getTag())->toBe('latest')
parent::setUp(); ->and($parser->isImageHash())->toBeFalse()
$this->parser = new DockerImageParser; ->and($parser->toString())->toBe('nginx:latest');
});
it('parses image with sha256 hash using colon format', function () {
$parser = new DockerImageParser;
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
$parser->parse("ghcr.io/benjaminehowe/rail-disruptions:{$hash}");
expect($parser->getFullImageNameWithoutTag())->toBe('ghcr.io/benjaminehowe/rail-disruptions')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->toString())->toBe("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}")
->and($parser->getFullImageNameWithHash())->toBe("ghcr.io/benjaminehowe/rail-disruptions@sha256:{$hash}");
});
it('parses image with sha256 hash using at sign format', function () {
$parser = new DockerImageParser;
$hash = '59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0';
$parser->parse("nginx@sha256:{$hash}");
expect($parser->getImageName())->toBe('nginx')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->toString())->toBe("nginx@sha256:{$hash}")
->and($parser->getFullImageNameWithHash())->toBe("nginx@sha256:{$hash}");
});
it('parses registry image with hash', function () {
$parser = new DockerImageParser;
$hash = 'abc123def456789abcdef123456789abcdef123456789abcdef123456789abc1';
$parser->parse("docker.io/library/nginx:{$hash}");
expect($parser->getFullImageNameWithoutTag())->toBe('docker.io/library/nginx')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->toString())->toBe("docker.io/library/nginx@sha256:{$hash}");
});
it('parses image without tag defaults to latest', function () {
$parser = new DockerImageParser;
$parser->parse('nginx');
expect($parser->getImageName())->toBe('nginx')
->and($parser->getTag())->toBe('latest')
->and($parser->isImageHash())->toBeFalse()
->and($parser->toString())->toBe('nginx:latest');
});
it('parses registry with port', function () {
$parser = new DockerImageParser;
$parser->parse('registry.example.com:5000/myapp:latest');
expect($parser->getFullImageNameWithoutTag())->toBe('registry.example.com:5000/myapp')
->and($parser->getTag())->toBe('latest')
->and($parser->isImageHash())->toBeFalse();
});
it('parses registry with port and hash', function () {
$parser = new DockerImageParser;
$hash = '1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef';
$parser->parse("registry.example.com:5000/myapp:{$hash}");
expect($parser->getFullImageNameWithoutTag())->toBe('registry.example.com:5000/myapp')
->and($parser->getTag())->toBe($hash)
->and($parser->isImageHash())->toBeTrue()
->and($parser->toString())->toBe("registry.example.com:5000/myapp@sha256:{$hash}");
});
it('identifies valid sha256 hashes', function () {
$validHashes = [
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf0',
'1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef',
'abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890',
];
foreach ($validHashes as $hash) {
$parser = new DockerImageParser;
$parser->parse("image:{$hash}");
expect($parser->isImageHash())->toBeTrue("Hash {$hash} should be recognized as valid SHA256");
} }
});
#[Test] it('identifies invalid sha256 hashes', function () {
public function it_parses_simple_image_name() $invalidHashes = [
{ 'latest',
$this->parser->parse('nginx'); 'v1.2.3',
'abc123', // too short
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf', // too short
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cf00', // too long
'59e02939b1bf39f16c93138a28727aec520bb916da021180ae502c61626b3cfg0', // invalid char
];
$this->assertEquals('', $this->parser->getRegistryUrl()); foreach ($invalidHashes as $hash) {
$this->assertEquals('nginx', $this->parser->getImageName()); $parser = new DockerImageParser;
$this->assertEquals('latest', $this->parser->getTag()); $parser->parse("image:{$hash}");
expect($parser->isImageHash())->toBeFalse("Hash {$hash} should not be recognized as valid SHA256");
} }
});
#[Test]
public function it_parses_image_with_tag()
{
$this->parser->parse('nginx:1.19');
$this->assertEquals('', $this->parser->getRegistryUrl());
$this->assertEquals('nginx', $this->parser->getImageName());
$this->assertEquals('1.19', $this->parser->getTag());
}
#[Test]
public function it_parses_image_with_organization()
{
$this->parser->parse('coollabs/coolify:latest');
$this->assertEquals('', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('latest', $this->parser->getTag());
}
#[Test]
public function it_parses_image_with_registry_url()
{
$this->parser->parse('ghcr.io/coollabs/coolify:v4');
$this->assertEquals('ghcr.io', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('v4', $this->parser->getTag());
}
#[Test]
public function it_parses_image_with_port_in_registry()
{
$this->parser->parse('localhost:5000/my-app:dev');
$this->assertEquals('localhost:5000', $this->parser->getRegistryUrl());
$this->assertEquals('my-app', $this->parser->getImageName());
$this->assertEquals('dev', $this->parser->getTag());
}
#[Test]
public function it_parses_image_without_tag()
{
$this->parser->parse('ghcr.io/coollabs/coolify');
$this->assertEquals('ghcr.io', $this->parser->getRegistryUrl());
$this->assertEquals('coollabs/coolify', $this->parser->getImageName());
$this->assertEquals('latest', $this->parser->getTag());
}
#[Test]
public function it_converts_back_to_string()
{
$originalString = 'ghcr.io/coollabs/coolify:v4';
$this->parser->parse($originalString);
$this->assertEquals($originalString, $this->parser->toString());
}
#[Test]
public function it_converts_to_string_with_default_tag()
{
$this->parser->parse('nginx');
$this->assertEquals('nginx:latest', $this->parser->toString());
}
}

View file

@ -1,10 +1,10 @@
{ {
"coolify": { "coolify": {
"v4": { "v4": {
"version": "4.0.0-beta.434" "version": "4.0.0-beta.435"
}, },
"nightly": { "nightly": {
"version": "4.0.0-beta.435" "version": "4.0.0-beta.436"
}, },
"helper": { "helper": {
"version": "1.0.11" "version": "1.0.11"