mirror of
https://github.com/coollabsio/coolify.git
synced 2026-10-28 17:44:36 +08:00
The compose parser queued ServerFilesFromServerJob for every volume on each parse, and pages that only show data parse the Compose file. Each sync also sent two SSH commands for each file. - Queue the sync only for a compose deployment, and for each service resource with file storages when the service starts or deploys. - Read the compose service names without a parse on the Domains and scheduled task pages. - Check all file storages with one remote command, and stop when a symbolic link, a special file, or an unknown state is at a file path. - Keep the application path in a file storage when a pull request preview is parsed. Preview deployments of the current parser write their `-pr-<id>` copies through LocalFileVolume::forPullRequest(). - Remove the unreachable file sync from the service resource page.
868 lines
33 KiB
PHP
868 lines
33 KiB
PHP
<?php
|
|
|
|
namespace App\Models;
|
|
|
|
use App\Events\FileStorageChanged;
|
|
use App\Jobs\ServerStorageSaveJob;
|
|
use Closure;
|
|
use Illuminate\Database\Eloquent\Casts\Attribute;
|
|
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
|
use Illuminate\Database\Eloquent\Relations\MorphMany;
|
|
use Illuminate\Database\Eloquent\Relations\MorphTo;
|
|
use Illuminate\Support\Collection;
|
|
use Illuminate\Support\Stringable;
|
|
|
|
class LocalFileVolume extends BaseModel
|
|
{
|
|
public const MAX_CONTENT_SIZE = 5_242_880;
|
|
|
|
public const BINARY_PLACEHOLDER = '[binary file]';
|
|
|
|
public const TOO_LARGE_PLACEHOLDER = '[file too large to display]';
|
|
|
|
/**
|
|
* Resolves $1 and $2 like `realpath -m`, but only with POSIX sh and `readlink -f`, which
|
|
* BusyBox also has. It walks up to the deepest existing path, resolves it, and appends the
|
|
* missing rest. A dangling symlink or `.`/`..` in the missing rest fails closed.
|
|
*/
|
|
private const REMOTE_PATH_CONFINEMENT_SCRIPT = <<<'SH'
|
|
resolve() {
|
|
path=$1
|
|
rest=
|
|
case $path in /*) ;; *) return 1 ;; esac
|
|
while [ ! -e "$path" ]; do
|
|
if [ -L "$path" ]; then return 1; fi
|
|
rest=/${path##*/}$rest
|
|
path=${path%/*}
|
|
[ -n "$path" ] || path=/
|
|
done
|
|
case "$rest/" in */./*|*/../*) return 1 ;; esac
|
|
path=$(readlink -f "$path") || return 1
|
|
printf "%s\n" "${path%/}$rest"
|
|
}
|
|
base=$(resolve "$1") || exit 1
|
|
target=$(resolve "$2") || exit 1
|
|
case $target in "$base"|"$base"/*) echo OK ;; *) echo NOK ;; esac
|
|
SH;
|
|
|
|
/**
|
|
* Prints `<number>:<state>` for each path argument, in order. It only reads. A state is `file`
|
|
* (also a symlink to a file), `missing`, `empty-directory`, `directory` (not empty) or `other`
|
|
* (another symlink or a special file). A directory that it cannot read is `directory`.
|
|
*/
|
|
private const REMOTE_FILE_STATE_SCRIPT = <<<'SH'
|
|
i=0
|
|
for path in "$@"; do
|
|
i=$((i + 1))
|
|
if [ -L "$path" ]; then
|
|
if [ -f "$path" ]; then state=file; else state=other; fi
|
|
elif [ -f "$path" ]; then
|
|
state=file
|
|
elif [ -d "$path" ]; then
|
|
state=directory
|
|
if entries=$(ls -A "$path" 2>/dev/null); then
|
|
if [ -z "$entries" ]; then state=empty-directory; fi
|
|
fi
|
|
elif [ -e "$path" ]; then
|
|
state=other
|
|
else
|
|
state=missing
|
|
fi
|
|
printf "%s:%s\n" "$i" "$state"
|
|
done
|
|
SH;
|
|
|
|
/**
|
|
* The pull request whose preview this copy writes. Not saved: see forPullRequest().
|
|
*/
|
|
protected int $previewPullRequestId = 0;
|
|
|
|
protected $casts = [
|
|
// 'fs_path' => 'encrypted',
|
|
// 'mount_path' => 'encrypted',
|
|
'content' => 'encrypted',
|
|
'is_directory' => 'boolean',
|
|
'is_host_file' => 'boolean',
|
|
'is_preview_suffix_enabled' => 'boolean',
|
|
];
|
|
|
|
protected $hidden = [
|
|
'content',
|
|
];
|
|
|
|
use HasFactory;
|
|
|
|
protected $fillable = [
|
|
'fs_path',
|
|
'mount_path',
|
|
'content',
|
|
'resource_type',
|
|
'resource_id',
|
|
'is_directory',
|
|
'is_host_file',
|
|
'is_based_on_git',
|
|
'is_preview_suffix_enabled',
|
|
];
|
|
|
|
public $appends = ['is_binary', 'is_too_large'];
|
|
|
|
protected static function booted()
|
|
{
|
|
static::created(function (LocalFileVolume $fileVolume) {
|
|
if ($fileVolume->is_host_file) {
|
|
return;
|
|
}
|
|
// A token with only the write permission can save a Compose file. A mount outside the resource
|
|
// directory is written when the resource is deployed or started, not when it is saved.
|
|
if ($fileVolume->isOutsideResourceDirectory()) {
|
|
return;
|
|
}
|
|
|
|
ServerStorageSaveJob::dispatch($fileVolume)->afterCommit();
|
|
});
|
|
|
|
static::deleting(function (LocalFileVolume $fileVolume): void {
|
|
if ($fileVolume->scheduledBackups()->exists()) {
|
|
throw new \RuntimeException('Delete this directory backup schedule and its archives before deleting the directory.');
|
|
}
|
|
});
|
|
}
|
|
|
|
protected function isBinary(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->content === self::BINARY_PLACEHOLDER
|
|
);
|
|
}
|
|
|
|
protected function isTooLarge(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->content === self::TOO_LARGE_PLACEHOLDER
|
|
);
|
|
}
|
|
|
|
public function resource(): MorphTo
|
|
{
|
|
return $this->morphTo();
|
|
}
|
|
|
|
public function service(): MorphTo
|
|
{
|
|
return $this->morphTo('resource');
|
|
}
|
|
|
|
public function scheduledBackups(): MorphMany
|
|
{
|
|
return $this->morphMany(ScheduledVolumeBackup::class, 'backupable');
|
|
}
|
|
|
|
public function abortIfScheduledBackupsExist(): void
|
|
{
|
|
if ($this->scheduledBackups()->exists()) {
|
|
abort(422, 'Delete this directory backup schedule and its archives before deleting the directory.');
|
|
}
|
|
}
|
|
|
|
public function loadStorageOnServer()
|
|
{
|
|
if ($this->is_host_file) {
|
|
return;
|
|
}
|
|
|
|
$this->load(['service']);
|
|
$isService = data_get($this->resource, 'service');
|
|
if ($isService) {
|
|
$workdir = $this->resource->service->workdir();
|
|
$server = $this->resource->service->server;
|
|
} else {
|
|
$workdir = $this->resource->workdir();
|
|
$server = $this->resource->destination->server;
|
|
}
|
|
$commands = collect([]);
|
|
$path = $this->resolvedFsPath($workdir);
|
|
|
|
if (! $this->isAdminControlledComposeMount()) {
|
|
[$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory();
|
|
if ($resourceDirectory !== null) {
|
|
$this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server);
|
|
}
|
|
$path = str($hostPath);
|
|
}
|
|
|
|
// Validate and escape path to prevent command injection
|
|
validateShellSafePath($path, 'storage path');
|
|
$escapedPath = escapeshellarg($path);
|
|
|
|
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
|
|
if ($isFile === 'OK') {
|
|
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
|
|
$this->content = self::TOO_LARGE_PLACEHOLDER;
|
|
$this->is_directory = false;
|
|
$this->save();
|
|
|
|
return;
|
|
}
|
|
$content = $this->readRemoteFileContent($escapedPath, $server);
|
|
// Check if content contains binary data by looking for null bytes or non-printable characters
|
|
if ($content !== self::TOO_LARGE_PLACEHOLDER && (str_contains($content, "\0") || preg_match('/[\x00-\x08\x0B\x0C\x0E-\x1F]/', $content))) {
|
|
$content = self::BINARY_PLACEHOLDER;
|
|
}
|
|
$this->content = $content;
|
|
$this->is_directory = false;
|
|
$this->save();
|
|
}
|
|
}
|
|
|
|
protected function remoteFileExceedsLimit(string $escapedPath, $server): bool
|
|
{
|
|
$sizeOutput = instant_remote_process(
|
|
["stat -c%s {$escapedPath} 2>/dev/null"],
|
|
$server,
|
|
false,
|
|
);
|
|
$size = (int) trim((string) $sizeOutput);
|
|
|
|
return $size > self::MAX_CONTENT_SIZE;
|
|
}
|
|
|
|
/**
|
|
* Cap the remote read itself so a file that grows after the size check
|
|
* cannot be fully slurped into PHP memory.
|
|
*/
|
|
protected function readRemoteFileContent(string $escapedPath, $server): string
|
|
{
|
|
$readLimit = self::MAX_CONTENT_SIZE + 1;
|
|
$content = instant_remote_process(["head -c {$readLimit} {$escapedPath}"], $server, false);
|
|
|
|
return self::contentFromBoundedRead($content);
|
|
}
|
|
|
|
public static function contentFromBoundedRead(?string $content): string
|
|
{
|
|
if (strlen((string) $content) > self::MAX_CONTENT_SIZE) {
|
|
return self::TOO_LARGE_PLACEHOLDER;
|
|
}
|
|
|
|
return (string) $content;
|
|
}
|
|
|
|
/**
|
|
* Without a server, Coolify deletes the file on every server of the resource.
|
|
* Host paths outside the resource directory are never deleted.
|
|
*/
|
|
public function deleteStorageOnServer(?Server $server = null)
|
|
{
|
|
if ($this->is_host_file) {
|
|
return;
|
|
}
|
|
if (is_null($server)) {
|
|
return $this->runOnEveryServer(fn (Server $server) => $this->deleteStorageOnServer($server));
|
|
}
|
|
|
|
$this->load(['service']);
|
|
$isService = data_get($this->resource, 'service');
|
|
$workdir = $isService ? $this->resource->service->workdir() : $this->resource->workdir();
|
|
$commands = collect([]);
|
|
$path = $this->resolvedFsPath($workdir);
|
|
|
|
if ($this->isOutsideResourceDirectory()) {
|
|
return null;
|
|
}
|
|
|
|
if (! $this->isAdminControlledComposeMount()) {
|
|
$path = str(confinePathToBase($workdir, $path->value(), 'storage path'));
|
|
$this->assertRemotePathIsConfined($workdir, $path->value(), $server);
|
|
}
|
|
|
|
// Validate and escape path to prevent command injection
|
|
validateShellSafePath($path, 'storage path');
|
|
$escapedPath = escapeshellarg($path);
|
|
|
|
$isFile = instant_remote_process(["test -f {$escapedPath} && echo OK || echo NOK"], $server);
|
|
$isDir = instant_remote_process(["test -d {$escapedPath} && echo OK || echo NOK"], $server);
|
|
if ($path && $path != '/' && $path != '.' && $path != '..') {
|
|
if ($isFile === 'OK') {
|
|
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
|
|
} elseif ($isDir === 'OK') {
|
|
$commands->push("rm -rf {$escapedPath} > /dev/null 2>&1 || true");
|
|
$commands->push("rmdir {$escapedPath} > /dev/null 2>&1 || true");
|
|
}
|
|
}
|
|
if ($commands->count() > 0) {
|
|
return instant_remote_process($commands, $server);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Without a server, Coolify writes the file on every server of the resource.
|
|
* Outside the resource directory, nothing is deleted or written through a symlink.
|
|
*/
|
|
public function saveStorageOnServer(?Server $server = null)
|
|
{
|
|
if ($this->is_host_file) {
|
|
return;
|
|
}
|
|
if (is_null($server)) {
|
|
return $this->runOnEveryServer(fn (Server $server) => $this->saveStorageOnServer($server));
|
|
}
|
|
|
|
$this->load(['service']);
|
|
$isService = data_get($this->resource, 'service');
|
|
$workdir = $isService ? $this->resource->service->workdir() : $this->resource->workdir();
|
|
$commands = collect([]);
|
|
$escapedWorkdir = escapeshellarg($workdir);
|
|
|
|
$path = $this->resolvedFsPath($workdir);
|
|
$content = data_get($this, 'content');
|
|
|
|
// A Compose bind mount (also with `content:`) uses the host path that the administrator wrote.
|
|
if (! $this->isAdminControlledComposeMount()) {
|
|
[$hostPath, $resourceDirectory] = $this->hostPathAndResourceDirectory();
|
|
if ($resourceDirectory === null) {
|
|
if (! $this->is_directory) {
|
|
self::assertRemotePathIsNotSymlink($hostPath, $server);
|
|
}
|
|
} else {
|
|
$this->assertRemotePathIsConfined($resourceDirectory, $hostPath, $server);
|
|
}
|
|
$path = str($hostPath);
|
|
}
|
|
$isOutsideResourceDirectory = $this->isOutsideResourceDirectory();
|
|
|
|
if ($this->is_directory) {
|
|
validateShellSafePath($path, 'storage path');
|
|
$commands->push('mkdir -p '.escapeshellarg($path).' > /dev/null 2>&1 || true');
|
|
$commands->push("mkdir -p {$escapedWorkdir} > /dev/null 2>&1 || true");
|
|
}
|
|
|
|
if ($path->startsWith('/') || $path->startsWith('~')) {
|
|
$parent_dir = $path->beforeLast('/');
|
|
if ($parent_dir != '') {
|
|
$escapedParentDir = escapeshellarg($parent_dir);
|
|
$commands->push("mkdir -p {$escapedParentDir} > /dev/null 2>&1 || true");
|
|
}
|
|
}
|
|
|
|
// Validate and escape resolved path (may differ from fs_path if relative)
|
|
validateShellSafePath($path, 'storage path');
|
|
$escapedPath = escapeshellarg($path);
|
|
|
|
$state = self::remoteFileStates([(string) $path], $server)[0];
|
|
if ($state === 'unknown') {
|
|
throw new \Exception("Coolify cannot check the file {$path} on the server.");
|
|
}
|
|
if ($state === 'other' && ! $this->is_directory) {
|
|
throw new \Exception("A symbolic link or a special file is at {$path} on the server. Coolify did not write the file.");
|
|
}
|
|
$isFile = $state === 'file';
|
|
$isDir = in_array($state, ['directory', 'empty-directory'], true);
|
|
$replacesEmptyDirectory = false;
|
|
if ($isFile && $this->is_directory) {
|
|
if ($this->remoteFileExceedsLimit($escapedPath, $server)) {
|
|
$this->content = self::TOO_LARGE_PLACEHOLDER;
|
|
} else {
|
|
$this->content = $this->readRemoteFileContent($escapedPath, $server);
|
|
}
|
|
$this->is_directory = false;
|
|
$this->save();
|
|
FileStorageChanged::dispatch(data_get($server, 'team_id'));
|
|
throw new \Exception('The following file is a file on the server, but you are trying to mark it as a directory. Please delete the file on the server or mark it as directory.');
|
|
} elseif ($isDir && ! $this->is_directory) {
|
|
if ($path === '/' || $path === '.' || $path === '..' || $path === '' || str($path)->isEmpty() || is_null($path)) {
|
|
$this->is_directory = true;
|
|
$this->save();
|
|
throw new \Exception('The following file is a directory on the server, but you are trying to mark it as a file. <br><br>Please delete the directory on the server or mark it as directory.');
|
|
}
|
|
// Docker creates a missing bind source as an empty directory. Replace only an empty
|
|
// directory inside the resource directory; never delete files that are on the server.
|
|
if ($isOutsideResourceDirectory || $state !== 'empty-directory') {
|
|
throw new \Exception("The following file is a directory on the server, but you are trying to mark it as a file: {$path}<br><br>Please delete the directory on the server or mark it as directory.");
|
|
}
|
|
$replacesEmptyDirectory = true;
|
|
}
|
|
if ((! $isDir || $replacesEmptyDirectory) && ! $this->is_directory) {
|
|
if ($replacesEmptyDirectory) {
|
|
$commands->push("rmdir {$escapedPath}");
|
|
}
|
|
$chmod = data_get($this, 'chmod');
|
|
$chown = data_get($this, 'chown');
|
|
if ($content) {
|
|
$content = base64_encode($content);
|
|
$commands->push("echo '$content' | base64 -d | tee {$escapedPath} > /dev/null");
|
|
} else {
|
|
$commands->push("touch {$escapedPath}");
|
|
}
|
|
$commands->push("chmod +x {$escapedPath}");
|
|
if ($chown) {
|
|
$commands->push('chown -- '.escapeshellarg($chown)." {$escapedPath}");
|
|
}
|
|
if ($chmod) {
|
|
$commands->push('chmod -- '.escapeshellarg($chmod)." {$escapedPath}");
|
|
}
|
|
} elseif (! $isDir && $this->is_directory) {
|
|
$commands->push("mkdir -p {$escapedPath} > /dev/null 2>&1 || true");
|
|
}
|
|
|
|
$result = instant_remote_process($commands, $server);
|
|
if ($replacesEmptyDirectory) {
|
|
FileStorageChanged::dispatch(data_get($server, 'team_id'));
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* One `sh -c` line with the paths as arguments, so the non-root sudo parser only puts sudo in
|
|
* front of it and never changes the script. See REMOTE_FILE_STATE_SCRIPT for the output.
|
|
*
|
|
* @param list<string> $paths
|
|
* @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg().
|
|
*/
|
|
public static function remoteFileStateCommand(array $paths, ?Closure $quote = null): string
|
|
{
|
|
return 'sh -c '.escapeshellarg(self::REMOTE_FILE_STATE_SCRIPT).' sh '.implode(' ', array_map($quote ?? escapeshellarg(...), $paths));
|
|
}
|
|
|
|
/**
|
|
* Gets the state of each path with one server command. A path without a state in the output is `unknown`.
|
|
*
|
|
* @param list<string> $paths
|
|
* @param (Closure(string): string)|null $quote Makes a shell argument of a path. The default is escapeshellarg().
|
|
* @return list<string>
|
|
*/
|
|
public static function remoteFileStates(array $paths, Server $server, ?Closure $quote = null): array
|
|
{
|
|
$states = [];
|
|
$output = (string) instant_remote_process([self::remoteFileStateCommand($paths, $quote)], $server, false);
|
|
foreach (preg_split('/\R/', trim($output)) as $line) {
|
|
if (preg_match('/^(\d+):([a-z-]+)$/', trim($line), $matches)) {
|
|
$states[(int) $matches[1] - 1] = $matches[2];
|
|
}
|
|
}
|
|
|
|
return array_map(fn (int $index) => $states[$index] ?? 'unknown', array_keys($paths));
|
|
}
|
|
|
|
/**
|
|
* The path where saveStorageOnServer() writes the content of this file on the server.
|
|
*
|
|
* @throws \Exception If the path is not allowed
|
|
*/
|
|
public function contentPathOnServer(): string
|
|
{
|
|
if ($this->isAdminControlledComposeMount()) {
|
|
return $this->resolvedFsPath($this->ownerResource()->workdir())->value();
|
|
}
|
|
|
|
return $this->hostPathAndResourceDirectory()[0];
|
|
}
|
|
|
|
/**
|
|
* `~` is not allowed because it depends on the home directory of the SSH user.
|
|
*
|
|
* @param string|list<string> $resourceDirectories The first directory resolves relative paths.
|
|
*
|
|
* @throws \Exception If the path is not allowed
|
|
*/
|
|
public static function resolveHostPath(string|array $resourceDirectories, string $path, string $context = 'storage path'): string
|
|
{
|
|
$resourceDirectories = array_map(normalizeUnixPath(...), (array) $resourceDirectories);
|
|
$path = trim($path);
|
|
if ($path === '') {
|
|
throw new \Exception("Invalid {$context}: the path is empty.");
|
|
}
|
|
if (str_starts_with($path, '~')) {
|
|
throw new \Exception("Invalid {$context}: use an absolute path instead of ~.");
|
|
}
|
|
validateShellSafePath($path, $context);
|
|
|
|
$isAbsolute = str_starts_with($path, '/');
|
|
$resolvedPath = normalizeUnixPath($isAbsolute ? $path : $resourceDirectories[0].'/'.$path);
|
|
if (self::resourceDirectoryContaining($resolvedPath, $resourceDirectories) !== null) {
|
|
return $resolvedPath;
|
|
}
|
|
|
|
if (! $isAbsolute) {
|
|
throw new \Exception("Invalid {$context}: a relative path must stay inside the resource directory.");
|
|
}
|
|
if (array_intersect(explode('/', $path), ['.', '..']) !== []) {
|
|
throw new \Exception("Invalid {$context}: '.' and '..' segments are not allowed.");
|
|
}
|
|
if ($resolvedPath === '/') {
|
|
throw new \Exception("Invalid {$context}: the root directory cannot be mounted.");
|
|
}
|
|
|
|
return $resolvedPath;
|
|
}
|
|
|
|
/**
|
|
* @throws \RuntimeException If Coolify must not use the path
|
|
*/
|
|
public static function assertHostPathOnServer(string $resourceDirectory, string $path, Server $server, bool $isDirectory): void
|
|
{
|
|
if (self::resourceDirectoryContaining($path, [normalizeUnixPath($resourceDirectory)]) !== null) {
|
|
self::assertRemotePathIsConfined($resourceDirectory, $path, $server);
|
|
} elseif (! $isDirectory) {
|
|
self::assertRemotePathIsNotSymlink($path, $server);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* @throws \RuntimeException If the path is a symbolic link on the server
|
|
*/
|
|
public static function assertRemotePathIsNotSymlink(string $path, Server $server): void
|
|
{
|
|
$escapedPath = escapeshellarg($path);
|
|
$result = instant_remote_process(["test -L {$escapedPath} && echo LINK || echo OK"], $server);
|
|
|
|
if (trim((string) $result) !== 'OK') {
|
|
throw new \RuntimeException("{$path} is a symbolic link on the server. Coolify does not write a file through a symbolic link. Remove the link or use another path.");
|
|
}
|
|
}
|
|
|
|
/**
|
|
* A path that cannot be resolved counts as outside.
|
|
*/
|
|
public function isOutsideResourceDirectory(): bool
|
|
{
|
|
$directories = $this->contentBaseDirectories();
|
|
try {
|
|
$path = normalizeUnixPath($this->resolvedFsPath($directories[0])->value());
|
|
$directories = array_map(normalizeUnixPath(...), $directories);
|
|
} catch (\Throwable) {
|
|
return true;
|
|
}
|
|
|
|
return str_starts_with($path, '~') || self::resourceDirectoryContaining($path, $directories) === null;
|
|
}
|
|
|
|
/**
|
|
* The resource directory is null when the host path is outside it.
|
|
*
|
|
* @return array{0: string, 1: string|null}
|
|
*
|
|
* @throws \Exception If the path is not allowed
|
|
*/
|
|
protected function hostPathAndResourceDirectory(): array
|
|
{
|
|
$directories = $this->contentBaseDirectories();
|
|
$path = self::resolveHostPath($directories, $this->resolvedFsPath($directories[0])->value());
|
|
|
|
return [$path, self::resourceDirectoryContaining($path, array_map(normalizeUnixPath(...), $directories))];
|
|
}
|
|
|
|
/**
|
|
* @param list<string> $directories Normalized directories
|
|
*/
|
|
protected static function resourceDirectoryContaining(string $path, array $directories): ?string
|
|
{
|
|
foreach ($directories as $directory) {
|
|
if ($path === $directory || str_starts_with($path, $directory.'/')) {
|
|
return $directory;
|
|
}
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* The host path of this mount. Like a relative bind source in Compose, a relative path is inside
|
|
* the resource directory. Remote commands run in the home directory of the SSH user, so they
|
|
* must never get the relative path. A `~` path stays in the home directory.
|
|
*/
|
|
public function resolvedFsPath(string $workdir): Stringable
|
|
{
|
|
$path = trim((string) $this->fs_path);
|
|
if (! str_starts_with($path, '/') && ! str_starts_with($path, '~')) {
|
|
if (str_starts_with($path, '.')) {
|
|
$path = substr($path, 1);
|
|
}
|
|
$path = ltrim($path, '/');
|
|
$path = rtrim($workdir, '/').($path === '' ? '' : '/'.$path);
|
|
}
|
|
|
|
return str(addPreviewDeploymentSuffix($path, $this->previewPullRequestId));
|
|
}
|
|
|
|
/**
|
|
* A copy that uses the path of the preview of a pull request, as the Compose parser mounts it
|
|
* (`<path>-pr-<id>` unless the PR suffix is off for this storage). The row keeps the path of
|
|
* the application, so saving the copy never stores the preview path.
|
|
*/
|
|
public function forPullRequest(int $pullRequestId): static
|
|
{
|
|
$copy = clone $this;
|
|
$copy->previewPullRequestId = $pullRequestId !== 0 && ($this->is_preview_suffix_enabled ?? true) ? $pullRequestId : 0;
|
|
|
|
return $copy;
|
|
}
|
|
|
|
/**
|
|
* Coolify writes this file from its content when the file is missing on the server. Files from
|
|
* the Git repository, host files and placeholders for binary or large files are not written.
|
|
*/
|
|
public function hasContentToWrite(): bool
|
|
{
|
|
return ! $this->is_directory
|
|
&& ! $this->is_host_file
|
|
&& ! $this->is_based_on_git
|
|
&& ! $this->is_binary
|
|
&& ! $this->is_too_large
|
|
&& (string) $this->content !== '';
|
|
}
|
|
|
|
/**
|
|
* Reject symlink escapes immediately before a managed path is used remotely.
|
|
*/
|
|
public static function assertRemotePathIsConfined(string $baseDirectory, string $path, Server $server): void
|
|
{
|
|
$result = instant_remote_process([self::remotePathConfinementCommand($baseDirectory, $path)], $server, false);
|
|
|
|
if (trim((string) $result) !== 'OK') {
|
|
throw new \RuntimeException('Invalid storage path: resolved path must stay inside the resource configuration directory.');
|
|
}
|
|
}
|
|
|
|
/**
|
|
* One `sh -c` line with the paths as arguments, so the non-root sudo parser only puts
|
|
* sudo in front of it and never changes the script.
|
|
*/
|
|
public static function remotePathConfinementCommand(string $baseDirectory, string $path): string
|
|
{
|
|
return 'sh -c '.escapeshellarg(self::REMOTE_PATH_CONFINEMENT_SCRIPT).' sh '.escapeshellarg($baseDirectory).' '.escapeshellarg($path);
|
|
}
|
|
|
|
/**
|
|
* The resource workdir, and the directory where the Compose parser resolves `./` sources. They
|
|
* differ only for services that use parser version 3.
|
|
*
|
|
* @return list<string>
|
|
*/
|
|
public function contentBaseDirectories(): array
|
|
{
|
|
return array_values(array_unique([$this->ownerResource()->workdir(), $this->composeSourceDirectory()]));
|
|
}
|
|
|
|
protected function composeSourceDirectory(): string
|
|
{
|
|
$owner = $this->ownerResource();
|
|
|
|
return $owner instanceof Application || $owner instanceof Service
|
|
? composeResourceDirectory($owner)
|
|
: $owner->workdir();
|
|
}
|
|
|
|
/**
|
|
* The main server of the resource, and the additional servers of an application.
|
|
*
|
|
* @return Collection<int, Server>
|
|
*/
|
|
public function servers(): Collection
|
|
{
|
|
$this->load(['service']);
|
|
if (data_get($this->resource, 'service')) {
|
|
return collect([$this->resource->service->server]);
|
|
}
|
|
|
|
$servers = collect([$this->resource->destination->server]);
|
|
if ($this->resource instanceof Application) {
|
|
$servers = $servers->merge($this->resource->additional_servers);
|
|
}
|
|
|
|
return $servers->filter()->unique('id')->values();
|
|
}
|
|
|
|
/**
|
|
* Runs the callback on every server, also when one server fails, and then throws the first error.
|
|
*
|
|
* @param Closure(Server): mixed $callback
|
|
*/
|
|
protected function runOnEveryServer(Closure $callback): mixed
|
|
{
|
|
$result = null;
|
|
$firstError = null;
|
|
foreach ($this->servers() as $server) {
|
|
try {
|
|
$result = $callback($server);
|
|
} catch (\Throwable $e) {
|
|
$firstError ??= $e;
|
|
}
|
|
}
|
|
if ($firstError) {
|
|
throw $firstError;
|
|
}
|
|
|
|
return $result;
|
|
}
|
|
|
|
/**
|
|
* The Application, Service or standalone database that owns the storage directory.
|
|
*/
|
|
protected function ownerResource(): mixed
|
|
{
|
|
$resource = $this->resource;
|
|
|
|
return $resource instanceof ServiceApplication || $resource instanceof ServiceDatabase
|
|
? $resource->service
|
|
: $resource;
|
|
}
|
|
|
|
/**
|
|
* Raw Compose bind mounts keep administrator-selected host path semantics.
|
|
*/
|
|
protected function isAdminControlledComposeMount(): bool
|
|
{
|
|
$compose = data_get($this->resource, 'docker_compose_raw')
|
|
?? data_get($this->resource, 'service.docker_compose_raw');
|
|
|
|
if (! is_string($compose) || $compose === '') {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
$services = data_get(parseDockerComposeYaml($compose), 'services', []);
|
|
foreach ($services as $service) {
|
|
foreach (data_get($service, 'volumes', []) as $volume) {
|
|
if (is_string($volume)) {
|
|
$parsed = parseDockerVolumeString($volume);
|
|
$source = data_get($parsed, 'source');
|
|
$target = data_get($parsed, 'target');
|
|
} else {
|
|
$source = data_get($volume, 'source');
|
|
$target = data_get($volume, 'target');
|
|
}
|
|
|
|
if ((string) $target !== $this->mount_path || ! sourceIsLocal(str((string) $source))) {
|
|
continue;
|
|
}
|
|
|
|
$sourceDirectory = str($this->composeSourceDirectory());
|
|
$fsPath = normalizeUnixPath($this->fs_path);
|
|
if (normalizeUnixPath(legacyReplaceLocalSource(str((string) $source), $sourceDirectory)->value()) === $fsPath) {
|
|
return true;
|
|
}
|
|
try {
|
|
$resolvedSource = replaceLocalSource(str((string) $source), $sourceDirectory)->value();
|
|
} catch (\Throwable) {
|
|
continue;
|
|
}
|
|
if (normalizeUnixPath($resolvedSource) === $fsPath) {
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
} catch (\Throwable) {
|
|
return false;
|
|
}
|
|
|
|
return false;
|
|
}
|
|
|
|
// Accessor for convenient access
|
|
protected function plainMountPath(): Attribute
|
|
{
|
|
return Attribute::make(
|
|
get: fn () => $this->mount_path,
|
|
set: fn ($value) => $this->mount_path = $value
|
|
);
|
|
}
|
|
|
|
// Scope for searching
|
|
public function scopeWherePlainMountPath($query, $path)
|
|
{
|
|
return $query->get()->where('plain_mount_path', $path);
|
|
}
|
|
|
|
// Check if this volume belongs to a service resource
|
|
public function isServiceResource(): bool
|
|
{
|
|
return in_array($this->resource_type, [
|
|
'App\Models\ServiceApplication',
|
|
'App\Models\ServiceDatabase',
|
|
]);
|
|
}
|
|
|
|
// Determine if this volume should be read-only in the UI
|
|
// File/directory mounts can be edited even for services
|
|
public function shouldBeReadOnlyInUI(): bool
|
|
{
|
|
// Check for explicit :ro flag in compose (existing logic)
|
|
return $this->isReadOnlyVolume();
|
|
}
|
|
|
|
// Check if this volume is read-only by parsing the docker-compose content
|
|
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 = parseDockerComposeYaml($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
|
|
// Note: We match on mount_path (container path) only, since fs_path gets transformed
|
|
// from relative (./file) to absolute (/data/coolify/services/uuid/file) during parsing
|
|
foreach ($volumes as $volume) {
|
|
// 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';
|
|
}
|
|
}
|
|
} elseif (is_array($volume)) {
|
|
// Long-form syntax: { type: bind, source: ..., target: ..., read_only: true }
|
|
$containerPath = data_get($volume, 'target');
|
|
$readOnly = data_get($volume, 'read_only', false);
|
|
|
|
// Match based on mount_path
|
|
// Remove leading slash from mount_path if present for comparison
|
|
$mountPath = str($this->mount_path)->ltrim('/')->toString();
|
|
$containerPathClean = str($containerPath)->ltrim('/')->toString();
|
|
|
|
if ($mountPath === $containerPathClean || $this->mount_path === $containerPath) {
|
|
return $readOnly === true;
|
|
}
|
|
}
|
|
}
|
|
|
|
return false;
|
|
} catch (\Throwable $e) {
|
|
|
|
return false;
|
|
}
|
|
}
|
|
}
|