mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-04 10:42:23 +00:00
feat: smart scan for git project creation (#8502)
Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
1 parent
73c4dbb00e
commit
51641c05b2
19 files changed
+1362
-16
No files matched your search
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
|
||||
namespace App\Data;
|
||||
|
||||
use App\Enums\BuildPackTypes;
|
||||
use Spatie\LaravelData\Data;
|
||||
|
||||
class RepositoryDetectionResult extends Data
|
||||
{
|
||||
/**
|
||||
* @param array<int, string> $dockerfiles e.g. ['Dockerfile', 'apps/api/Dockerfile']
|
||||
* @param array<int, string> $dockerComposeFiles e.g. ['docker-compose.yml']
|
||||
* @param array<string, string|null> $envFiles e.g. ['.env.example' => 'KEY=val...', '.env.dist' => null]
|
||||
* @param array<string, int|null> $dockerfilePorts e.g. ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080]
|
||||
*/
|
||||
public function __construct(
|
||||
public array $dockerfiles = [],
|
||||
public array $dockerComposeFiles = [],
|
||||
public array $envFiles = [],
|
||||
public array $dockerfilePorts = [],
|
||||
) {}
|
||||
|
||||
public static function none(): self
|
||||
{
|
||||
return new self;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build pack for the files in the base directory. Files in subdirectories are often examples,
|
||||
* tests, or other services, so they do not select a build pack.
|
||||
*/
|
||||
public function getSuggestedBuildPack(): ?BuildPackTypes
|
||||
{
|
||||
if ($this->baseDirectoryFile($this->dockerComposeFiles)) {
|
||||
return BuildPackTypes::DOCKERCOMPOSE;
|
||||
}
|
||||
|
||||
if ($this->baseDirectoryFile($this->dockerfiles)) {
|
||||
return BuildPackTypes::DOCKERFILE;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The first file that is directly in the base directory, not in a subdirectory.
|
||||
*
|
||||
* @param array<int, string> $files
|
||||
*/
|
||||
public function baseDirectoryFile(array $files): ?string
|
||||
{
|
||||
return collect($files)->first(fn (string $file): bool => ! str_contains($file, '/'));
|
||||
}
|
||||
|
||||
public function hasDockerfile(): bool
|
||||
{
|
||||
return count($this->dockerfiles) > 0;
|
||||
}
|
||||
|
||||
public function hasDockerCompose(): bool
|
||||
{
|
||||
return count($this->dockerComposeFiles) > 0;
|
||||
}
|
||||
|
||||
public function hasEnvFiles(): bool
|
||||
{
|
||||
return count($this->envFiles) > 0;
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ use App\Models\GithubApp;
|
||||
use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasRepositoryDetection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Facades\Http;
|
||||
use Illuminate\Support\Facades\Route;
|
||||
@@ -16,6 +17,7 @@ use Livewire\Component;
|
||||
class GithubPrivateRepository extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasRepositoryDetection;
|
||||
|
||||
public $current_step = 'github_apps';
|
||||
|
||||
@@ -151,6 +153,8 @@ class GithubPrivateRepository extends Component
|
||||
$this->selected_branch_name = $this->branches->contains('name', $defaultBranch)
|
||||
? $defaultBranch
|
||||
: data_get($this->branches, '0.name', 'main');
|
||||
|
||||
$this->detectRepository();
|
||||
}
|
||||
|
||||
protected function loadBranchByPage()
|
||||
@@ -173,6 +177,15 @@ class GithubPrivateRepository extends Component
|
||||
$this->branches = $this->branches->concat(collect($json));
|
||||
}
|
||||
|
||||
protected function applicationForDetection(): Application
|
||||
{
|
||||
return $this->unsavedApplicationForDetection(
|
||||
str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(),
|
||||
$this->selected_branch_name,
|
||||
$this->github_app,
|
||||
);
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
try {
|
||||
@@ -205,7 +218,7 @@ class GithubPrivateRepository extends Component
|
||||
$project = Project::ownedByCurrentTeam()->where('uuid', $this->parameters['project_uuid'])->firstOrFail();
|
||||
$environment = $project->environments()->where('uuid', $this->parameters['environment_uuid'])->firstOrFail();
|
||||
|
||||
$application = new Application([
|
||||
$application_init = [
|
||||
'name' => generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name),
|
||||
'repository_project_id' => $this->selected_repository_id,
|
||||
'git_repository' => str($this->selected_repository_owner)->trim()->toString().'/'.str($this->selected_repository_repo)->trim()->toString(),
|
||||
@@ -219,23 +232,31 @@ class GithubPrivateRepository extends Component
|
||||
'destination_type' => $destination_class,
|
||||
'source_id' => $this->github_app->id,
|
||||
'source_type' => $this->github_app->getMorphClass(),
|
||||
]);
|
||||
];
|
||||
|
||||
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
|
||||
$application_init['health_check_enabled'] = false;
|
||||
}
|
||||
if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) {
|
||||
$application_init['dockerfile_location'] = $dockerfileLocation;
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application_init['docker_compose_location'] = $this->docker_compose_location;
|
||||
}
|
||||
|
||||
$application = new Application($application_init);
|
||||
$application->save();
|
||||
$application->settings->is_static = $this->is_static;
|
||||
$application->settings->save();
|
||||
|
||||
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
|
||||
$application->health_check_enabled = false;
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application['docker_compose_location'] = $this->docker_compose_location;
|
||||
}
|
||||
$fqdn = generateUrl(server: $destination->server, random: $application->uuid);
|
||||
$application->fqdn = $fqdn;
|
||||
|
||||
$application->name = generate_application_name($this->selected_repository_owner.'/'.$this->selected_repository_repo, $this->selected_branch_name, $application->uuid);
|
||||
$application->save();
|
||||
|
||||
$this->importDetectedEnvironmentVariables($application);
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
||||
@@ -10,6 +10,7 @@ use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasRepositoryDetection;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Illuminate\Support\Str;
|
||||
use Livewire\Component;
|
||||
@@ -18,6 +19,7 @@ use Spatie\Url\Url;
|
||||
class GithubPrivateRepositoryDeployKey extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasRepositoryDetection;
|
||||
|
||||
public $current_step = 'private_keys';
|
||||
|
||||
@@ -132,6 +134,18 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
$this->current_step = 'repository';
|
||||
}
|
||||
|
||||
protected function applicationForDetection(): Application
|
||||
{
|
||||
$this->get_git_source();
|
||||
|
||||
return $this->unsavedApplicationForDetection(
|
||||
$this->git_repository,
|
||||
$this->branch ?? 'main',
|
||||
$this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null,
|
||||
PrivateKey::ownedByCurrentTeam()->findOrFail($this->private_key_id),
|
||||
);
|
||||
}
|
||||
|
||||
public function submit()
|
||||
{
|
||||
$this->authorize('create', Application::class);
|
||||
@@ -186,6 +200,9 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
|
||||
$application_init['health_check_enabled'] = false;
|
||||
}
|
||||
if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) {
|
||||
$application_init['dockerfile_location'] = $dockerfileLocation;
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application_init['docker_compose_location'] = $this->docker_compose_location;
|
||||
$application_init['base_directory'] = $this->base_directory;
|
||||
@@ -200,6 +217,8 @@ class GithubPrivateRepositoryDeployKey extends Component
|
||||
$application->name = generate_random_name($application->uuid);
|
||||
$application->save();
|
||||
|
||||
$this->importDetectedEnvironmentVariables($application);
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
||||
@@ -9,6 +9,7 @@ use App\Models\Project;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Rules\ValidGitRepositoryUrl;
|
||||
use App\Support\ValidationPatterns;
|
||||
use App\Traits\HasRepositoryDetection;
|
||||
use Carbon\Carbon;
|
||||
use Illuminate\Foundation\Auth\Access\AuthorizesRequests;
|
||||
use Livewire\Component;
|
||||
@@ -17,6 +18,7 @@ use Spatie\Url\Url;
|
||||
class PublicGitRepository extends Component
|
||||
{
|
||||
use AuthorizesRequests;
|
||||
use HasRepositoryDetection;
|
||||
|
||||
public string $repository_url;
|
||||
|
||||
@@ -167,17 +169,17 @@ class PublicGitRepository extends Component
|
||||
$this->git_branch = 'master';
|
||||
}
|
||||
$this->selectedBranch = $this->git_branch;
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
if ($this->rate_limit_remaining == 0) {
|
||||
$this->selectedBranch = $this->git_branch;
|
||||
$this->branchFound = true;
|
||||
|
||||
return;
|
||||
}
|
||||
if (! $this->branchFound && $this->git_branch === 'main') {
|
||||
} elseif (! $this->branchFound && $this->git_branch === 'main') {
|
||||
try {
|
||||
$this->git_branch = 'master';
|
||||
$this->getBranch();
|
||||
|
||||
} catch (\Throwable $e) {
|
||||
return handleError($e, $this);
|
||||
}
|
||||
@@ -185,6 +187,19 @@ class PublicGitRepository extends Component
|
||||
return handleError($e, $this);
|
||||
}
|
||||
}
|
||||
if ($this->branchFound) {
|
||||
$this->selectedBranch = $this->git_branch;
|
||||
$this->detectRepository();
|
||||
}
|
||||
}
|
||||
|
||||
protected function applicationForDetection(): Application
|
||||
{
|
||||
return $this->unsavedApplicationForDetection(
|
||||
$this->git_repository,
|
||||
$this->git_branch,
|
||||
$this->git_source instanceof GithubApp || $this->git_source instanceof GitlabApp ? $this->git_source : null,
|
||||
);
|
||||
}
|
||||
|
||||
private function getGitSource()
|
||||
@@ -331,6 +346,9 @@ class PublicGitRepository extends Component
|
||||
if ($this->build_pack === 'dockerfile' || $this->build_pack === 'dockerimage') {
|
||||
$application_init['health_check_enabled'] = false;
|
||||
}
|
||||
if ($this->build_pack === 'dockerfile' && $dockerfileLocation = $this->selectedDockerfileLocation()) {
|
||||
$application_init['dockerfile_location'] = $dockerfileLocation;
|
||||
}
|
||||
if ($this->build_pack === 'dockercompose') {
|
||||
$application_init['docker_compose_location'] = $this->docker_compose_location;
|
||||
$application_init['base_directory'] = $this->base_directory;
|
||||
@@ -344,6 +362,8 @@ class PublicGitRepository extends Component
|
||||
$application->fqdn = $fqdn;
|
||||
$application->save();
|
||||
|
||||
$this->importDetectedEnvironmentVariables($application);
|
||||
|
||||
return redirect()->route('project.application.configuration', [
|
||||
'application_uuid' => $application->uuid,
|
||||
'environment_uuid' => $environment->uuid,
|
||||
|
||||
@@ -2280,6 +2280,17 @@ class Application extends BaseModel
|
||||
return $reason ? '<br><br>Reason: '.e(str($reason)->limit(300)->value()) : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* One shell command that clones the repository without a checkout into $checkoutDir on the server,
|
||||
* with the same credentials (GitHub App token, GitLab token, or deploy key) as a deployment.
|
||||
*/
|
||||
public function serverCheckoutCommand(string $uuid, string $checkoutDir): string
|
||||
{
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir);
|
||||
|
||||
return str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand));
|
||||
}
|
||||
|
||||
/**
|
||||
* Commands that check out only the Compose file on the server and print it. They run on the
|
||||
* server itself, not in a helper container, so the checkout uses an absolute folder in /tmp.
|
||||
@@ -2289,8 +2300,7 @@ class Application extends BaseModel
|
||||
private function composeFileReadCommands(string $uuid, string $gitVersion): Collection
|
||||
{
|
||||
$checkoutDir = "/tmp/{$uuid}/checkout";
|
||||
['commands' => $cloneCommand] = $this->generateGitImportCommands(deployment_uuid: $uuid, only_checkout: true, exec_in_docker: false, custom_base_dir: $checkoutDir);
|
||||
$cloneCommand = str_replace(' clone ', ' clone --quiet ', $this->gitCommandsAsShellCommand($cloneCommand));
|
||||
$cloneCommand = $this->serverCheckoutCommand($uuid, $checkoutDir);
|
||||
$workdir = rtrim($this->base_directory, '/');
|
||||
$fileList = collect([".{$workdir}{$this->docker_compose_location}"]);
|
||||
$composeFilePath = escapeshellarg(".{$workdir}{$this->docker_compose_location}");
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use App\Data\RepositoryDetectionResult;
|
||||
use App\Models\Application;
|
||||
use App\Models\Server;
|
||||
use Illuminate\Support\Collection;
|
||||
use RuntimeException;
|
||||
|
||||
class RepositoryDetector
|
||||
{
|
||||
/**
|
||||
* Largest env template that is read from the repository, in bytes.
|
||||
*/
|
||||
private const MAX_ENV_FILE_BYTES = 65536;
|
||||
|
||||
/**
|
||||
* @param Application $application Unsaved application with the repository, branch, and Git source or deploy key.
|
||||
*/
|
||||
public function __construct(
|
||||
private Application $application,
|
||||
private string $baseDirectory,
|
||||
private Server $server,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws RuntimeException When the repository cannot be read.
|
||||
*/
|
||||
public function detect(): RepositoryDetectionResult
|
||||
{
|
||||
$uuid = new_public_id();
|
||||
|
||||
try {
|
||||
$output = instant_remote_process($this->scanCommands($uuid), $this->server, timeout: 60);
|
||||
} finally {
|
||||
instant_remote_process(["rm -rf /tmp/{$uuid}"], $this->server, false);
|
||||
}
|
||||
|
||||
return $this->parseOutput((string) str($output)->trim()->afterLast("\n"));
|
||||
}
|
||||
|
||||
/**
|
||||
* Clones the repository without a checkout and lists the files with `git ls-tree`, so no file is
|
||||
* written to the disk. Each command is one line because non-root servers send every line through
|
||||
* parseCommandsByLineForSudo().
|
||||
*
|
||||
* @return Collection<int, string>
|
||||
*/
|
||||
protected function scanCommands(string $uuid): Collection
|
||||
{
|
||||
$checkoutDir = "/tmp/{$uuid}/checkout";
|
||||
$baseDir = trim($this->baseDirectory, '/');
|
||||
if (preg_match('~(^|/)\.\.?(/|$)~', $baseDir) || ! preg_match('~^[a-zA-Z0-9_./-]*$~', $baseDir)) {
|
||||
throw new RuntimeException('Invalid repository base directory.');
|
||||
}
|
||||
$tree = escapeshellarg($baseDir === '' ? 'HEAD' : "HEAD:{$baseDir}");
|
||||
$maxEnvBytes = self::MAX_ENV_FILE_BYTES;
|
||||
|
||||
$classifyFiles = <<<'AWK'
|
||||
{ split($1, meta, " "); if (meta[1] != "100644" && meta[1] != "100755") next; path = $2; lower = tolower(path); name = lower; sub(/.*\//, "", name); if (name ~ /^dockerfile(\.[a-z0-9_-]+)?$/) kind = "dockerfile"; else if (name ~ /^(docker-)?compose\.ya?ml$/) kind = "compose"; else if (lower ~ /^\.env\.(example|sample|template|dist|local\.example)$/) kind = "env"; else next; print kind "\t" meta[3] "\t" path }
|
||||
AWK;
|
||||
$exposedPort = <<<'AWK'
|
||||
{ sub(/\r$/, "") } toupper($1) == "EXPOSE" { split($2, p, "/"); if (p[1] ~ /^[0-9]+$/ && p[1] + 0 > 0 && p[1] + 0 <= 65535) print p[1] + 0; exit }
|
||||
AWK;
|
||||
$summary = '{dockerfiles: map(select(.kind == "dockerfile") | .file), dockerComposeFiles: map(select(.kind == "compose") | .file), envFiles: (map(select(.kind == "env") | {(.file): .content}) | add // {}), dockerfilePorts: (map(select(.kind == "dockerfile") | {(.file): .port}) | add // {})}';
|
||||
|
||||
$scan = 'tab=$(printf "\t"); '
|
||||
."git -c core.quotePath=false ls-tree -r {$tree} | awk -F '\\t' ".escapeshellarg($classifyFiles)
|
||||
.' | while IFS="$tab" read -r kind object file; do case "$kind" in '
|
||||
.'dockerfile) port=$(git cat-file blob "$object" | awk '.escapeshellarg($exposedPort).'); '
|
||||
.'jq -nc --arg file "$file" --argjson port "${port:-null}" \'{kind: "dockerfile", file: $file, port: $port}\' ;; '
|
||||
.'compose) jq -nc --arg file "$file" \'{kind: "compose", file: $file}\' ;; '
|
||||
."env) git cat-file blob \"\$object\" | head -c {$maxEnvBytes} | jq -Rsc --arg file \"\$file\" '{kind: \"env\", file: \$file, content: .}' ;; "
|
||||
.'esac; done | jq -sc '.escapeshellarg($summary);
|
||||
|
||||
return collect([
|
||||
"rm -rf /tmp/{$uuid}",
|
||||
"mkdir -p /tmp/{$uuid}",
|
||||
"cd /tmp/{$uuid}",
|
||||
'sh -c '.escapeshellarg($this->application->serverCheckoutCommand($uuid, $checkoutDir)),
|
||||
"cd {$checkoutDir}",
|
||||
'sh -c '.escapeshellarg($scan),
|
||||
]);
|
||||
}
|
||||
|
||||
protected function parseOutput(string $output): RepositoryDetectionResult
|
||||
{
|
||||
$data = json_decode(trim($output), true);
|
||||
|
||||
if (! is_array($data)) {
|
||||
return RepositoryDetectionResult::none();
|
||||
}
|
||||
|
||||
foreach (['dockerfiles', 'dockerComposeFiles', 'envFiles', 'dockerfilePorts'] as $key) {
|
||||
if (isset($data[$key]) && ! is_array($data[$key])) {
|
||||
return RepositoryDetectionResult::none();
|
||||
}
|
||||
}
|
||||
|
||||
$dockerfilePorts = [];
|
||||
foreach ($data['dockerfilePorts'] ?? [] as $file => $port) {
|
||||
$dockerfilePorts[$file] = (is_int($port) || (is_string($port) && ctype_digit($port))) && (int) $port > 0 && (int) $port <= 65535 ? (int) $port : null;
|
||||
}
|
||||
|
||||
return new RepositoryDetectionResult(
|
||||
dockerfiles: $this->sortByPreference(array_filter($data['dockerfiles'] ?? [], 'is_string'), '/^dockerfile$/i'),
|
||||
dockerComposeFiles: $this->sortByPreference(array_filter($data['dockerComposeFiles'] ?? [], 'is_string'), '/^docker-compose\.ya?ml$/i'),
|
||||
envFiles: array_filter($data['envFiles'] ?? [], fn ($content) => is_string($content) || $content === null),
|
||||
dockerfilePorts: $dockerfilePorts,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Puts the file nearest to the base directory first. At the same depth, a file with the default
|
||||
* name (for example `Dockerfile`) comes before a variant. Other files keep the scan order.
|
||||
*
|
||||
* @param array<int|string, string> $files
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function sortByPreference(array $files, string $defaultNamePattern): array
|
||||
{
|
||||
$files = array_values($files);
|
||||
usort($files, fn (string $a, string $b): int => [substr_count($a, '/'), ! preg_match($defaultNamePattern, basename($a))]
|
||||
<=> [substr_count($b, '/'), ! preg_match($defaultNamePattern, basename($b))]);
|
||||
|
||||
return $files;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Data\RepositoryDetectionResult;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\EnvironmentVariable;
|
||||
use App\Models\GithubApp;
|
||||
use App\Models\GitlabApp;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Rules\ValidGitBranch;
|
||||
use App\Services\RepositoryDetector;
|
||||
use App\Support\ValidationPatterns;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
use Illuminate\Support\Facades\Validator;
|
||||
use Livewire\Attributes\Locked;
|
||||
|
||||
/**
|
||||
* @property string $build_pack
|
||||
* @property int $port
|
||||
* @property string|null $docker_compose_location
|
||||
*
|
||||
* @method void updatedBuildPack()
|
||||
*/
|
||||
trait HasRepositoryDetection
|
||||
{
|
||||
public bool $detectionRan = false;
|
||||
|
||||
public bool $detectionFailed = false;
|
||||
|
||||
/**
|
||||
* Build pack that the scan selected from a file in the base directory.
|
||||
*/
|
||||
#[Locked]
|
||||
public ?string $suggestedBuildPack = null;
|
||||
|
||||
#[Locked]
|
||||
public array $detectedDockerfiles = [];
|
||||
|
||||
#[Locked]
|
||||
public array $detectedDockerComposeFiles = [];
|
||||
|
||||
public ?string $selectedDockerfile = null;
|
||||
|
||||
public ?string $selectedDockerComposeFile = null;
|
||||
|
||||
public ?int $detectedPort = null;
|
||||
|
||||
#[Locked]
|
||||
public array $dockerfilePorts = [];
|
||||
|
||||
#[Locked]
|
||||
public array $detectedEnvFiles = [];
|
||||
|
||||
public ?string $selectedEnvFile = null;
|
||||
|
||||
#[Locked]
|
||||
public array $parsedEnvFiles = [];
|
||||
|
||||
public array $envExampleVars = [];
|
||||
|
||||
public bool $envImported = false;
|
||||
|
||||
/**
|
||||
* Unsaved application with the repository, branch, and Git source or deploy key to scan.
|
||||
*/
|
||||
abstract protected function applicationForDetection(): Application;
|
||||
|
||||
public function detectRepository(): void
|
||||
{
|
||||
$this->authorize('create', Application::class);
|
||||
try {
|
||||
$application = $this->applicationForDetection();
|
||||
} catch (\Throwable $e) {
|
||||
handleError($e, $this);
|
||||
|
||||
return;
|
||||
}
|
||||
Validator::make([
|
||||
'branch' => $application->git_branch,
|
||||
'base_directory' => $this->base_directory ?? '/',
|
||||
], [
|
||||
'branch' => ['required', new ValidGitBranch],
|
||||
'base_directory' => ['required', 'string', 'regex:~^/?(?:[a-zA-Z0-9_.-]+/)*[a-zA-Z0-9_.-]*$~', 'not_regex:~(^|/)\.\.?(/|$)~'],
|
||||
])->validate();
|
||||
|
||||
$destination = find_resource_destination_for_current_team(data_get($this->query, 'destination'));
|
||||
if (! $destination) {
|
||||
$this->dispatch('error', 'Destination not found.');
|
||||
|
||||
return;
|
||||
}
|
||||
$this->authorize('update', $destination->server);
|
||||
|
||||
$this->detectionRan = false;
|
||||
$this->detectionFailed = false;
|
||||
$this->applyDetectionResult(RepositoryDetectionResult::none());
|
||||
try {
|
||||
$detector = new RepositoryDetector($application, $this->base_directory ?? '/', $destination->server);
|
||||
$this->applyDetectionResult($detector->detect());
|
||||
} catch (\Throwable $e) {
|
||||
// The exception can contain the clone command with a Git token, so do not log the message.
|
||||
Log::debug('Repository detection failed', ['exception' => $e::class]);
|
||||
$this->detectionFailed = true;
|
||||
$this->dispatch('error', 'Smart Scan could not read the repository. Check the repository access and the branch.');
|
||||
}
|
||||
$this->detectionRan = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an application that is not saved, only to generate the clone command for the scan.
|
||||
*/
|
||||
protected function unsavedApplicationForDetection(string $repository, string $branch, GithubApp|GitlabApp|null $source = null, ?PrivateKey $privateKey = null): Application
|
||||
{
|
||||
$application = (new Application)->forceFill([
|
||||
'git_repository' => $repository,
|
||||
'git_branch' => $branch,
|
||||
'git_commit_sha' => 'HEAD',
|
||||
'private_key_id' => $privateKey?->id,
|
||||
]);
|
||||
$application->setRelation('settings', (new ApplicationSetting)->forceFill([
|
||||
'is_git_shallow_clone_enabled' => true,
|
||||
'is_git_submodules_enabled' => false,
|
||||
]));
|
||||
$application->setRelation('source', $source);
|
||||
$application->setRelation('private_key', $privateKey);
|
||||
|
||||
return $application;
|
||||
}
|
||||
|
||||
protected function importDetectedEnvironmentVariables(Application $application): void
|
||||
{
|
||||
if (! $this->envImported || empty($this->envExampleVars)) {
|
||||
return;
|
||||
}
|
||||
Validator::make(['variables' => $this->envExampleVars], [
|
||||
'variables' => ['array'],
|
||||
'variables.*' => ['nullable', 'string'],
|
||||
])->validate();
|
||||
$application->getConnection()->transaction(function () use ($application): void {
|
||||
foreach ($this->envExampleVars as $key => $value) {
|
||||
if (! preg_match('/^[A-Z_][A-Z0-9_]*$/i', (string) $key)) {
|
||||
continue;
|
||||
}
|
||||
EnvironmentVariable::create([
|
||||
'key' => $key,
|
||||
'value' => $value,
|
||||
'resourceable_type' => $application->getMorphClass(),
|
||||
'resourceable_id' => $application->id,
|
||||
'is_preview' => false,
|
||||
]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
protected function applyDetectionResult(RepositoryDetectionResult $result): void
|
||||
{
|
||||
$this->detectedDockerfiles = $result->dockerfiles;
|
||||
$this->detectedDockerComposeFiles = $result->dockerComposeFiles;
|
||||
$this->dockerfilePorts = $result->dockerfilePorts;
|
||||
|
||||
$this->selectedDockerfile = null;
|
||||
$this->selectedDockerComposeFile = null;
|
||||
$this->detectedPort = null;
|
||||
$this->detectedEnvFiles = [];
|
||||
$this->parsedEnvFiles = [];
|
||||
$this->selectedEnvFile = null;
|
||||
$this->envExampleVars = [];
|
||||
$this->envImported = false;
|
||||
|
||||
$this->suggestedBuildPack = $result->getSuggestedBuildPack()?->value;
|
||||
if ($this->suggestedBuildPack && $this->suggestedBuildPack !== $this->build_pack) {
|
||||
$this->build_pack = $this->suggestedBuildPack;
|
||||
$this->updatedBuildPack();
|
||||
}
|
||||
|
||||
if ($result->hasDockerfile()) {
|
||||
$this->selectedDockerfile = $result->dockerfiles[0];
|
||||
$port = $result->dockerfilePorts[$this->selectedDockerfile] ?? null;
|
||||
$this->port = $port ?? 3000;
|
||||
$this->detectedPort = $port;
|
||||
}
|
||||
|
||||
if ($result->hasDockerCompose()) {
|
||||
$this->selectedDockerComposeFile = $result->dockerComposeFiles[0];
|
||||
$this->docker_compose_location = '/'.$this->selectedDockerComposeFile;
|
||||
}
|
||||
|
||||
if ($result->hasEnvFiles()) {
|
||||
$this->detectedEnvFiles = array_keys($result->envFiles);
|
||||
$this->parsedEnvFiles = [];
|
||||
foreach ($result->envFiles as $filename => $content) {
|
||||
$parsed = $content !== null ? parseEnvFormatToArray($content) : [];
|
||||
// parseEnvFormatToArray returns ['KEY' => ['value' => ..., 'comment' => ...]].
|
||||
// Flatten to a plain ['KEY' => 'value'] map for the import form.
|
||||
$this->parsedEnvFiles[$filename] = collect($parsed)
|
||||
->map(fn ($item) => is_array($item) ? (string) ($item['value'] ?? '') : (string) $item)
|
||||
->all();
|
||||
}
|
||||
$this->selectedEnvFile = $this->detectedEnvFiles[0];
|
||||
$this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? [];
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Repository path without a leading slash, so a typed path matches the detected paths.
|
||||
*/
|
||||
protected function normalizeRepositoryFilePath(?string $path): ?string
|
||||
{
|
||||
$path = ltrim(trim((string) $path), '/');
|
||||
|
||||
return $path === '' ? null : $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Dockerfile location for the new application: a detected file or a path the user typed.
|
||||
*/
|
||||
protected function selectedDockerfileLocation(): ?string
|
||||
{
|
||||
$path = $this->normalizeRepositoryFilePath($this->selectedDockerfile);
|
||||
if ($path === null) {
|
||||
return null;
|
||||
}
|
||||
$location = '/'.$path;
|
||||
Validator::make(
|
||||
['selectedDockerfile' => $location],
|
||||
['selectedDockerfile' => ValidationPatterns::filePathRules()],
|
||||
ValidationPatterns::filePathMessages('selectedDockerfile', 'Dockerfile'),
|
||||
)->validate();
|
||||
|
||||
return $location;
|
||||
}
|
||||
|
||||
public function updatedSelectedDockerfile(): void
|
||||
{
|
||||
$this->selectedDockerfile = $this->normalizeRepositoryFilePath($this->selectedDockerfile);
|
||||
$this->resetErrorBag('selectedDockerfile');
|
||||
$this->selectedDockerfileLocation();
|
||||
|
||||
$port = $this->dockerfilePorts[$this->selectedDockerfile ?? ''] ?? null;
|
||||
$this->port = $port ?? 3000;
|
||||
$this->detectedPort = $port;
|
||||
}
|
||||
|
||||
public function updatedSelectedDockerComposeFile(): void
|
||||
{
|
||||
$this->selectedDockerComposeFile = $this->normalizeRepositoryFilePath($this->selectedDockerComposeFile);
|
||||
|
||||
if ($this->selectedDockerComposeFile) {
|
||||
$this->docker_compose_location = '/'.$this->selectedDockerComposeFile;
|
||||
}
|
||||
}
|
||||
|
||||
public function updatedSelectedEnvFile(): void
|
||||
{
|
||||
if ($this->selectedEnvFile && isset($this->parsedEnvFiles[$this->selectedEnvFile])) {
|
||||
$this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile];
|
||||
$this->envImported = false;
|
||||
}
|
||||
}
|
||||
|
||||
public function confirmEnvImport(): void
|
||||
{
|
||||
$this->envImported = true;
|
||||
}
|
||||
|
||||
public function clearEnvVars(): void
|
||||
{
|
||||
$this->envImported = false;
|
||||
$this->envExampleVars = $this->parsedEnvFiles[$this->selectedEnvFile] ?? [];
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,7 @@
|
||||
'searchable' => false, // true = a search field filters the options
|
||||
'searchPlaceholder' => 'Search…',
|
||||
'searchEmptyText' => 'No matching options',
|
||||
'allowCustom' => false, // true (with searchable) = the search text can be chosen as a custom value
|
||||
])
|
||||
|
||||
@php
|
||||
@@ -92,9 +93,15 @@
|
||||
return this.options.filter((option) => !option.header && [option.label, option.value, option.description]
|
||||
.some((text) => String(text ?? '').toLowerCase().includes(query)));
|
||||
},
|
||||
get customOption() {
|
||||
const query = this.query.trim();
|
||||
if (!@js($allowCustom) || query === '' || this.options.some((option) => !option.header && String(option.value) === query)) return null;
|
||||
return { value: query, label: query };
|
||||
},
|
||||
chooseOnlyMatch() {
|
||||
const matches = this.visibleOptions.filter((option) => !option.disabled);
|
||||
if (matches.length === 1) this.choose(matches[0]);
|
||||
else if (this.customOption) this.choose(this.customOption);
|
||||
},
|
||||
@else
|
||||
get visibleOptions() {
|
||||
@@ -126,7 +133,8 @@
|
||||
},
|
||||
get current() {
|
||||
const found = this.options.find((option) => String(option.value) === String(this.value));
|
||||
return found ? found.label : @js($placeholder);
|
||||
if (found) return found.label;
|
||||
return @js($allowCustom) && this.value ? String(this.value) : @js($placeholder);
|
||||
},
|
||||
get title() {
|
||||
return this.current;
|
||||
@@ -237,7 +245,7 @@
|
||||
</div>
|
||||
<div class="searchable-listbox-options">
|
||||
@endif
|
||||
<div x-show="visibleOptions.length === 0"
|
||||
<div x-show="visibleOptions.length === 0{{ $searchable && $allowCustom ? ' && !customOption' : '' }}"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
@if ($searchable)
|
||||
<span x-text="options.length === 0 ? @js($emptyText) : @js($searchEmptyText)"></span>
|
||||
@@ -272,6 +280,14 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@if ($searchable && $allowCustom)
|
||||
<template x-if="customOption">
|
||||
<button type="button" class="listbox-option" role="option" aria-selected="false"
|
||||
@click="choose(customOption)">
|
||||
<span class="truncate">Use <span class="font-mono" x-text="customOption.label"></span></span>
|
||||
</button>
|
||||
</template>
|
||||
@endif
|
||||
@if ($searchable)
|
||||
</div>
|
||||
@endif
|
||||
@@ -296,7 +312,7 @@
|
||||
</div>
|
||||
<div class="searchable-listbox-options">
|
||||
@endif
|
||||
<div x-show="visibleOptions.length === 0"
|
||||
<div x-show="visibleOptions.length === 0{{ $searchable && $allowCustom ? ' && !customOption' : '' }}"
|
||||
class="px-3 py-2 text-[13px] text-neutral-500 dark:text-fg-dim">
|
||||
@if ($searchable)
|
||||
<span x-text="options.length === 0 ? @js($emptyText) : @js($searchEmptyText)"></span>
|
||||
@@ -329,6 +345,14 @@
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@if ($searchable && $allowCustom)
|
||||
<template x-if="customOption">
|
||||
<button type="button" class="listbox-option" role="option" aria-selected="false"
|
||||
@click="choose(customOption)">
|
||||
<span class="truncate">Use <span class="font-mono" x-text="customOption.label"></span></span>
|
||||
</button>
|
||||
</template>
|
||||
@endif
|
||||
@if ($searchable)
|
||||
</div>
|
||||
@endif
|
||||
|
||||
@@ -8,6 +8,7 @@
|
||||
$icons = [
|
||||
'fire' => '<path d="M12.83 2.18a.75.75 0 0 0-1.15.64c0 2.04-.88 3.72-2.31 5.19-.45-1.13-.59-2.31-.42-3.55a.75.75 0 0 0-1.17-.72C4.55 5.96 2.75 9.25 2.75 13a9.25 9.25 0 0 0 18.5 0c0-4.72-3.28-8.6-8.42-10.82ZM12 20.75A7.75 7.75 0 0 1 4.25 13c0-2.67 1.07-5.1 3.05-6.94.08 1.37.46 2.68 1.12 3.93a.75.75 0 0 0 1.18.19c1.85-1.62 3.03-3.48 3.43-5.6 4.13 2.09 6.72 5.13 6.72 8.42A7.75 7.75 0 0 1 12 20.75Z" fill="currentColor"/><path d="M12.38 10.13a.75.75 0 0 0-1.26.32c-.33 1.2-1.06 2.06-1.7 2.82-.69.81-1.17 1.52-1.17 2.73a3.75 3.75 0 0 0 7.5 0c0-2.13-1.2-4.1-3.37-5.87ZM12 18.25A2.25 2.25 0 0 1 9.75 16c0-.65.24-1.03.82-1.72.48-.57 1.03-1.21 1.45-2.06 1.47 1.37 2.23 2.63 2.23 3.78A2.25 2.25 0 0 1 12 18.25Z" fill="currentColor"/>',
|
||||
'cloud' => '<path d="M7.5 19.25h9.75a4.5 4.5 0 0 0 .62-8.96A6.25 6.25 0 0 0 5.8 8.65 5.25 5.25 0 0 0 7.5 19.25Z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'brain' => '<g stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><path d="M12 18V5"/><path d="M15 13a4.17 4.17 0 0 1-3-4 4.17 4.17 0 0 1-3 4"/><path d="M17.598 6.5A3 3 0 1 0 12 5a3 3 0 1 0-5.598 1.5"/><path d="M17.997 5.125a4 4 0 0 1 2.526 5.77"/><path d="M18 18a4 4 0 0 0 2-7.464"/><path d="M19.967 17.483A4 4 0 1 1 12 18a4 4 0 1 1-7.967-.517"/><path d="M6 18a4 4 0 0 1-2-7.464"/><path d="M6.003 5.125a4 4 0 0 0-2.526 5.77"/></g>',
|
||||
'code' => '<path d="m8.5 7-5 5 5 5M15.5 7l5 5-5 5M13.5 4l-3 16" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'analytics' => '<path d="M5 3v14a2 2 0 0 0 2 2h14" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/><path d="M9 17v-3.5M13 17v-7M17 17v-10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
|
||||
'mail' => '<path fill-rule="evenodd" clip-rule="evenodd" d="M5.5 3.25h13A4.25 4.25 0 0 1 22.75 7.5v9a4.25 4.25 0 0 1-4.25 4.25h-13A4.25 4.25 0 0 1 1.25 16.5v-9A4.25 4.25 0 0 1 5.5 3.25Zm-2.75 4.5v8.75a2.75 2.75 0 0 0 2.75 2.75h13a2.75 2.75 0 0 0 2.75-2.75V7.75l-6.6 5.13a4.25 4.25 0 0 1-5.3 0l-6.6-5.13Zm18.4-1.3A2.75 2.75 0 0 0 18.5 4.75h-13a2.75 2.75 0 0 0-2.65 1.7l7.42 5.77a2.75 2.75 0 0 0 3.46 0l7.42-5.77Z" fill="currentColor"/>',
|
||||
|
||||
@@ -103,6 +103,25 @@
|
||||
<x-forms.input wire:model="base_directory" label="Base directory"
|
||||
helper="Repository directory used as the build root." />
|
||||
@endif
|
||||
|
||||
@include('livewire.project.new.partials.smart-scan-summary')
|
||||
|
||||
{{-- Dockerfile: pick a detected file or type a path --}}
|
||||
@if ($build_pack === 'dockerfile')
|
||||
<x-forms.listbox id="selectedDockerfile" label="Dockerfile" live searchable allowCustom placeholder="/Dockerfile"
|
||||
emptyText="No Dockerfiles detected. Type a path in the search field."
|
||||
searchPlaceholder="Search or type a path…"
|
||||
helper="Select a detected Dockerfile, or type a path relative to the base directory."
|
||||
:options="collect($detectedDockerfiles)->map(fn ($file) => ['value' => $file, 'label' => $file])->all()" />
|
||||
@endif
|
||||
|
||||
{{-- Docker Compose file selector: pick a detected file or type a path --}}
|
||||
@if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0)
|
||||
<x-forms.listbox id="selectedDockerComposeFile" label="Docker Compose File" live searchable allowCustom
|
||||
searchPlaceholder="Search or type a path…"
|
||||
helper="Select a detected Docker Compose file, or type a path relative to the base directory."
|
||||
:options="collect($detectedDockerComposeFiles)->map(fn ($file) => ['value' => $file, 'label' => $file])->all()" />
|
||||
@endif
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
|
||||
@@ -104,6 +104,8 @@
|
||||
<x-forms.button type="submit" wire:target="submit" isHighlighted>Continue</x-forms.button>
|
||||
</div>
|
||||
<div class="application-settings-section-body space-y-5">
|
||||
@include('livewire.project.new.partials.smart-scan-summary')
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-forms.listbox searchable id="selected_branch_name" label="Branch" required
|
||||
searchPlaceholder="Search branches…"
|
||||
@@ -134,6 +136,23 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Dockerfile: pick a detected file or type a path --}}
|
||||
@if ($build_pack === 'dockerfile')
|
||||
<x-forms.listbox id="selectedDockerfile" label="Dockerfile" live searchable allowCustom placeholder="/Dockerfile"
|
||||
emptyText="No Dockerfiles detected. Type a path in the search field."
|
||||
searchPlaceholder="Search or type a path…"
|
||||
helper="Select a detected Dockerfile, or type a path relative to the base directory."
|
||||
:options="collect($detectedDockerfiles)->map(fn ($file) => ['value' => $file, 'label' => $file])->all()" />
|
||||
@endif
|
||||
|
||||
{{-- Docker Compose file selector: pick a detected file or type a path --}}
|
||||
@if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0)
|
||||
<x-forms.listbox id="selectedDockerComposeFile" label="Docker Compose File" live searchable allowCustom
|
||||
searchPlaceholder="Search or type a path…"
|
||||
helper="Select a detected Docker Compose file, or type a path relative to the base directory."
|
||||
:options="collect($detectedDockerComposeFiles)->map(fn ($file) => ['value' => $file, 'label' => $file])->all()" />
|
||||
@endif
|
||||
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: @js($base_directory),
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
<x-modal-input title="Import environment variables" :wireIgnore="false"
|
||||
subtitle="The variables are added to the application when you continue.">
|
||||
<x-slot:content>
|
||||
<x-forms.button type="button">{{ $envImported ? 'Edit' : 'Review' }}</x-forms.button>
|
||||
</x-slot:content>
|
||||
|
||||
<div class="flex flex-col gap-4">
|
||||
@if (count($detectedEnvFiles) > 1)
|
||||
<x-forms.listbox id="selectedEnvFile" label="Env file" live
|
||||
:options="collect($detectedEnvFiles)->map(fn ($file) => ['value' => $file, 'label' => $file])->all()" />
|
||||
@endif
|
||||
<div class="flex flex-col gap-2">
|
||||
@foreach ($envExampleVars as $key => $value)
|
||||
<div wire:key="env-import-{{ $selectedEnvFile }}-{{ $key }}"
|
||||
class="grid grid-cols-1 items-center gap-1 sm:grid-cols-[minmax(0,1fr)_minmax(0,2fr)] sm:gap-3">
|
||||
<span class="truncate font-mono text-[13px] text-neutral-600 dark:text-fg-dim" title="{{ $key }}">{{ $key }}</span>
|
||||
<x-forms.input id="envExampleVars.{{ $key }}" />
|
||||
</div>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<x-slot:footer>
|
||||
@if ($envImported)
|
||||
<x-forms.button type="button" wire:click="clearEnvVars" @click="modalOpen = false">Remove import</x-forms.button>
|
||||
@endif
|
||||
<x-forms.button type="button" isHighlighted wire:click="confirmEnvImport" @click="modalOpen = false">
|
||||
{{ $envImported ? 'Update' : 'Import' }} {{ count($envExampleVars) }} {{ Str::plural('variable', count($envExampleVars)) }}
|
||||
</x-forms.button>
|
||||
</x-slot:footer>
|
||||
</x-modal-input>
|
||||
@@ -0,0 +1,73 @@
|
||||
{{--
|
||||
Smart Scan result inside the build configuration, as a compact divided list: the build pack,
|
||||
the Dockerfile port, and the env file import. The scan button sends the current base directory,
|
||||
so the user can scan a subdirectory after changing it.
|
||||
--}}
|
||||
@php
|
||||
$buildPackLabels = ['dockercompose' => 'Docker Compose', 'dockerfile' => 'Dockerfile'];
|
||||
$suggestedFile = match ($suggestedBuildPack) {
|
||||
'dockercompose' => $detectedDockerComposeFiles[0] ?? null,
|
||||
'dockerfile' => $detectedDockerfiles[0] ?? null,
|
||||
default => null,
|
||||
};
|
||||
$nestedFiles = collect([
|
||||
count($detectedDockerfiles) ? count($detectedDockerfiles).' '.Str::plural('Dockerfile', count($detectedDockerfiles)) : null,
|
||||
count($detectedDockerComposeFiles) ? count($detectedDockerComposeFiles).' '.Str::plural('Compose file', count($detectedDockerComposeFiles)) : null,
|
||||
])->filter()->join(' and ');
|
||||
$showPort = $detectionRan && $build_pack === 'dockerfile' && in_array($selectedDockerfile, $detectedDockerfiles, true);
|
||||
@endphp
|
||||
|
||||
<div class="overflow-hidden rounded-[10px] border border-neutral-200 dark:border-white/[0.08]">
|
||||
<div class="flex min-h-10 items-center justify-between gap-3 border-b border-neutral-200 py-1.5 pr-1.5 pl-4 dark:border-white/[0.08]">
|
||||
<p class="text-[10px] font-semibold uppercase tracking-[0.08em] text-neutral-400 dark:text-fg-faint">
|
||||
Smart Scan
|
||||
</p>
|
||||
<x-forms.button type="button" wire:click="detectRepository">
|
||||
<x-reicon name="search" class="size-3.5" />
|
||||
{{ $detectionRan ? 'Scan again' : 'Scan repository' }}
|
||||
</x-forms.button>
|
||||
</div>
|
||||
|
||||
<div wire:loading.block wire:target="detectRepository">
|
||||
<x-checkpoint-item status="running" title="Scanning the repository"
|
||||
description="Looking for Dockerfiles, Compose files, and env files." />
|
||||
</div>
|
||||
|
||||
<div wire:loading.remove wire:target="detectRepository" class="divide-y divide-neutral-200 dark:divide-white/[0.07]">
|
||||
@if (! $detectionRan)
|
||||
<x-checkpoint-item icon="search" title="Not scanned yet"
|
||||
description="Scan the repository to find Dockerfiles, Compose files, and env files." />
|
||||
@elseif ($detectionFailed)
|
||||
<x-checkpoint-item status="error" title="Could not read the repository"
|
||||
description="Check the repository access and the branch, or select the build pack manually." />
|
||||
@elseif ($suggestedBuildPack)
|
||||
<x-checkpoint-item status="success"
|
||||
title="{{ $buildPackLabels[$suggestedBuildPack] }} {{ $build_pack === $suggestedBuildPack ? 'selected' : 'suggested' }}"
|
||||
description="Found {{ $suggestedFile }} in the base directory." />
|
||||
@elseif ($nestedFiles !== '')
|
||||
<x-checkpoint-item icon="brain" title="Found {{ $nestedFiles }} in subdirectories" />
|
||||
@else
|
||||
<x-checkpoint-item icon="brain" title="No Dockerfile or Compose file found"
|
||||
description="Select the build pack that fits the repository." />
|
||||
@endif
|
||||
|
||||
@if ($showPort)
|
||||
@if ($detectedPort)
|
||||
<x-checkpoint-item status="success" title="Port {{ $detectedPort }}"
|
||||
description="From EXPOSE in {{ $selectedDockerfile }}." />
|
||||
@else
|
||||
<x-checkpoint-item icon="globe" title="Port {{ $port }}"
|
||||
description="{{ $selectedDockerfile }} has no EXPOSE instruction, so the default port is used." />
|
||||
@endif
|
||||
@endif
|
||||
|
||||
@if ($detectionRan && count($envExampleVars) > 0)
|
||||
<div class="flex items-center justify-between gap-3 pr-4">
|
||||
<x-checkpoint-item :status="$envImported ? 'success' : 'idle'" icon="variables"
|
||||
title="{{ $selectedEnvFile }}"
|
||||
description="{{ count($envExampleVars) }} {{ Str::plural('variable', count($envExampleVars)) }}{{ $envImported ? ', imported when you continue' : '' }}." />
|
||||
@include('livewire.project.new.partials.env-import-modal')
|
||||
</div>
|
||||
@endif
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,6 +56,8 @@
|
||||
</x-callout>
|
||||
@endif
|
||||
|
||||
@include('livewire.project.new.partials.smart-scan-summary')
|
||||
|
||||
<div class="grid gap-4 sm:grid-cols-2">
|
||||
<x-forms.input id="git_branch" label="Branch"
|
||||
:disabled="$git_source !== 'other'"
|
||||
@@ -83,6 +85,23 @@
|
||||
@endif
|
||||
</div>
|
||||
|
||||
{{-- Dockerfile: pick a detected file or type a path --}}
|
||||
@if ($build_pack === 'dockerfile')
|
||||
<x-forms.listbox id="selectedDockerfile" label="Dockerfile" live searchable allowCustom placeholder="/Dockerfile"
|
||||
emptyText="No Dockerfiles detected. Type a path in the search field."
|
||||
searchPlaceholder="Search or type a path…"
|
||||
helper="Select a detected Dockerfile, or type a path relative to the base directory."
|
||||
:options="collect($detectedDockerfiles)->map(fn ($file) => ['value' => $file, 'label' => $file])->all()" />
|
||||
@endif
|
||||
|
||||
{{-- Docker Compose file selector: pick a detected file or type a path --}}
|
||||
@if ($build_pack === 'dockercompose' && count($detectedDockerComposeFiles) > 0)
|
||||
<x-forms.listbox id="selectedDockerComposeFile" label="Docker Compose File" live searchable allowCustom
|
||||
searchPlaceholder="Search or type a path…"
|
||||
helper="Select a detected Docker Compose file, or type a path relative to the base directory."
|
||||
:options="collect($detectedDockerComposeFiles)->map(fn ($file) => ['value' => $file, 'label' => $file])->all()" />
|
||||
@endif
|
||||
|
||||
@if ($build_pack === 'dockercompose')
|
||||
<div x-data="{
|
||||
baseDir: @js($base_directory),
|
||||
|
||||
@@ -97,6 +97,24 @@ test('searchable listbox renders a search field with its placeholders', function
|
||||
->toContain('No matching server');
|
||||
});
|
||||
|
||||
test('searchable listbox with allowCustom offers the typed value as an option', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-forms.listbox id="path" :wire="false" searchable allowCustom :options="[['value' => 'Dockerfile', 'label' => 'Dockerfile']]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)
|
||||
->toContain('x-if="customOption"')
|
||||
->toContain('!customOption');
|
||||
});
|
||||
|
||||
test('listbox without allowCustom offers no custom option', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-forms.listbox id="path" :wire="false" searchable :options="[['value' => 'Dockerfile', 'label' => 'Dockerfile']]" />
|
||||
BLADE);
|
||||
|
||||
expect($html)->not->toContain('x-if="customOption"');
|
||||
});
|
||||
|
||||
test('listbox without searchable renders no search field', function () {
|
||||
$html = Blade::render(<<<'BLADE'
|
||||
<x-forms.listbox id="server" :wire="false" :options="[['value' => 1, 'label' => 'One']]" />
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
<?php
|
||||
|
||||
use App\Livewire\Project\New\GithubPrivateRepository;
|
||||
use App\Livewire\Project\New\GithubPrivateRepositoryDeployKey;
|
||||
use App\Livewire\Project\New\PublicGitRepository;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\PrivateKey;
|
||||
use App\Models\Server;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Services\RepositoryDetector;
|
||||
use Illuminate\Auth\Access\AuthorizationException;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Livewire\Features\SupportLockedProperties\CannotUpdateLockedPropertyException;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
beforeEach(function () {
|
||||
$this->team = Team::factory()->create();
|
||||
$this->user = User::factory()->create();
|
||||
$this->actingAs($this->user);
|
||||
session(['currentTeam' => ['id' => $this->team->id]]);
|
||||
});
|
||||
|
||||
test('members cannot start scans in any creation flow', function (string $componentClass) {
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'member']);
|
||||
$component = new $componentClass;
|
||||
expect(fn () => $component->detectRepository())->toThrow(AuthorizationException::class);
|
||||
})->with([PublicGitRepository::class, GithubPrivateRepository::class, GithubPrivateRepositoryDeployKey::class]);
|
||||
|
||||
test('owners cannot use scan base directory traversal', function () {
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$component = new PublicGitRepository;
|
||||
$component->git_repository = 'https://github.com/test/repo';
|
||||
$component->base_directory = '/../outside';
|
||||
expect(fn () => $component->detectRepository())->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('scan does not run on a destination from another team', function () {
|
||||
$this->team->members()->attach($this->user->id, ['role' => 'owner']);
|
||||
$otherServer = Server::factory()->create(['team_id' => Team::factory()->create()->id]);
|
||||
$otherDestination = $otherServer->standaloneDockers()->firstOrFail();
|
||||
|
||||
Livewire\Livewire::test(RepositoryScanStateTestComponent::class)
|
||||
->set('git_repository', 'https://github.com/test/repo')
|
||||
->set('query', ['destination' => $otherDestination->uuid])
|
||||
->call('detectRepository')
|
||||
->assertDispatched('error', 'Destination not found.')
|
||||
->assertSet('detectionRan', false);
|
||||
});
|
||||
|
||||
test('environment import stores valid keys and skips malformed keys', function () {
|
||||
$application = Application::factory()->create(['build_pack' => 'dockerfile']);
|
||||
$component = new class extends PublicGitRepository
|
||||
{
|
||||
public function importInto(Application $application): void
|
||||
{
|
||||
$this->importDetectedEnvironmentVariables($application);
|
||||
}
|
||||
};
|
||||
$component->envImported = true;
|
||||
$component->envExampleVars = ['VALID_KEY' => 'value', 'invalid.key' => 'ignored', '1BAD' => 'ignored'];
|
||||
$component->importInto($application);
|
||||
expect($application->environment_variables()->pluck('value', 'key')->all())->toBe(['VALID_KEY' => 'value']);
|
||||
});
|
||||
|
||||
class RepositoryScanStateTestComponent extends PublicGitRepository
|
||||
{
|
||||
public function mount() {}
|
||||
|
||||
public function render()
|
||||
{
|
||||
return '<div></div>';
|
||||
}
|
||||
}
|
||||
|
||||
test('clients cannot change the detected Dockerfile allowlist', function () {
|
||||
expect(fn () => Livewire\Livewire::test(RepositoryScanStateTestComponent::class)
|
||||
->set('detectedDockerfiles', ['../../outside/Dockerfile']))
|
||||
->toThrow(CannotUpdateLockedPropertyException::class);
|
||||
});
|
||||
|
||||
test('deploy key scans clone with the key, like a deployment', function () {
|
||||
$application = (new Application)->forceFill(['git_repository' => 'git@github.com:test/private.git', 'git_branch' => 'main', 'git_commit_sha' => 'HEAD', 'private_key_id' => 5]);
|
||||
$application->setRelation('settings', (new ApplicationSetting)->forceFill(['is_git_shallow_clone_enabled' => true, 'is_git_submodules_enabled' => false]));
|
||||
$application->setRelation('source', null);
|
||||
$application->setRelation('private_key', (new PrivateKey)->forceFill(['private_key' => 'test-key']));
|
||||
$detector = new RepositoryDetector($application, '/', new Server);
|
||||
|
||||
$clone = (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, 'scan-test')->get(3);
|
||||
|
||||
expect($clone)->toStartWith('sh -c ')
|
||||
->toContain(base64_encode('test-key'))
|
||||
->toContain('-i /root/.ssh/id_rsa_coolify_scan-test')
|
||||
->toContain('git@github.com:test/private.git');
|
||||
});
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
use App\Data\RepositoryDetectionResult;
|
||||
use App\Models\Application;
|
||||
use App\Traits\HasRepositoryDetection;
|
||||
use Illuminate\Validation\ValidationException;
|
||||
use Tests\TestCase;
|
||||
|
||||
uses(TestCase::class);
|
||||
|
||||
/**
|
||||
* Minimal host that mimics the Livewire components using the trait.
|
||||
*/
|
||||
class RepositoryDetectionHost
|
||||
{
|
||||
use HasRepositoryDetection;
|
||||
|
||||
public string $build_pack = 'railpack';
|
||||
|
||||
public int $port = 3000;
|
||||
|
||||
public ?string $docker_compose_location = '/docker-compose.yaml';
|
||||
|
||||
protected function applicationForDetection(): Application
|
||||
{
|
||||
return $this->unsavedApplicationForDetection('test/repo', 'main');
|
||||
}
|
||||
|
||||
public function updatedBuildPack(): void {}
|
||||
|
||||
public function resetErrorBag($field = null): void {}
|
||||
|
||||
public function dockerfileLocation(): ?string
|
||||
{
|
||||
return $this->selectedDockerfileLocation();
|
||||
}
|
||||
|
||||
public function apply(RepositoryDetectionResult $result): void
|
||||
{
|
||||
$this->applyDetectionResult($result);
|
||||
}
|
||||
}
|
||||
|
||||
test('env vars are flattened to plain key value strings for the import form', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
|
||||
$host->apply(new RepositoryDetectionResult(
|
||||
envFiles: [
|
||||
'.env.example' => "APP_NAME=MyApp\nAPP_DEBUG=true\nAPP_URL=\"https://example.com\"",
|
||||
],
|
||||
));
|
||||
|
||||
expect($host->envExampleVars)->toBe([
|
||||
'APP_NAME' => 'MyApp',
|
||||
'APP_DEBUG' => 'true',
|
||||
'APP_URL' => 'https://example.com',
|
||||
]);
|
||||
|
||||
// No value should be an array (which would render as "[object Object]" in the UI).
|
||||
foreach ($host->envExampleVars as $value) {
|
||||
expect($value)->toBeString();
|
||||
}
|
||||
});
|
||||
|
||||
test('switching the selected env file keeps values flat', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
|
||||
$host->apply(new RepositoryDetectionResult(
|
||||
envFiles: [
|
||||
'.env.example' => 'FOO=bar',
|
||||
'.env.sample' => 'BAZ=qux',
|
||||
],
|
||||
));
|
||||
|
||||
$host->selectedEnvFile = '.env.sample';
|
||||
$host->updatedSelectedEnvFile();
|
||||
|
||||
expect($host->envExampleVars)->toBe(['BAZ' => 'qux']);
|
||||
});
|
||||
|
||||
test('empty scan preserves the chosen build pack and clears previous results', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
$host->apply(new RepositoryDetectionResult(envFiles: ['.env.example' => 'FOO=bar']));
|
||||
$host->envImported = true;
|
||||
$host->apply(RepositoryDetectionResult::none());
|
||||
|
||||
expect($host->build_pack)->toBe('railpack')
|
||||
->and($host->envExampleVars)->toBe([])
|
||||
->and($host->detectedEnvFiles)->toBe([])
|
||||
->and($host->selectedEnvFile)->toBeNull()
|
||||
->and($host->envImported)->toBeFalse();
|
||||
});
|
||||
|
||||
test('switching to a Dockerfile without EXPOSE resets the detected port', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
$host->apply(new RepositoryDetectionResult(
|
||||
dockerfiles: ['Dockerfile', 'Dockerfile.worker'],
|
||||
dockerfilePorts: ['Dockerfile' => 8080, 'Dockerfile.worker' => null],
|
||||
));
|
||||
$host->selectedDockerfile = 'Dockerfile.worker';
|
||||
$host->updatedSelectedDockerfile();
|
||||
|
||||
expect($host->port)->toBe(3000)->and($host->detectedPort)->toBeNull();
|
||||
});
|
||||
|
||||
test('a typed Dockerfile path that was not detected is kept', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
$host->apply(new RepositoryDetectionResult(dockerfiles: ['Dockerfile'], dockerfilePorts: ['Dockerfile' => 8080]));
|
||||
$host->selectedDockerfile = ' /docker/prod.Dockerfile ';
|
||||
$host->updatedSelectedDockerfile();
|
||||
|
||||
expect($host->selectedDockerfile)->toBe('docker/prod.Dockerfile')
|
||||
->and($host->dockerfileLocation())->toBe('/docker/prod.Dockerfile')
|
||||
->and($host->port)->toBe(3000);
|
||||
});
|
||||
|
||||
test('a typed Dockerfile path with unsafe characters is rejected', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
$host->selectedDockerfile = 'Dockerfile; rm -rf /';
|
||||
|
||||
expect(fn () => $host->updatedSelectedDockerfile())->toThrow(ValidationException::class);
|
||||
});
|
||||
|
||||
test('an empty Dockerfile selection keeps the default location', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
$host->selectedDockerfile = ' ';
|
||||
$host->updatedSelectedDockerfile();
|
||||
|
||||
expect($host->selectedDockerfile)->toBeNull()->and($host->dockerfileLocation())->toBeNull();
|
||||
});
|
||||
|
||||
test('a typed Docker Compose path sets the compose location', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
$host->apply(new RepositoryDetectionResult(dockerComposeFiles: ['docker-compose.yml']));
|
||||
$host->selectedDockerComposeFile = '/deploy/compose.prod.yaml';
|
||||
$host->updatedSelectedDockerComposeFile();
|
||||
|
||||
expect($host->selectedDockerComposeFile)->toBe('deploy/compose.prod.yaml')
|
||||
->and($host->docker_compose_location)->toBe('/deploy/compose.prod.yaml');
|
||||
});
|
||||
|
||||
test('nested build files do not change the build pack', function () {
|
||||
$host = new RepositoryDetectionHost;
|
||||
$host->apply(new RepositoryDetectionResult(
|
||||
dockerfiles: ['apps/api/Dockerfile'],
|
||||
dockerComposeFiles: ['examples/demo/docker-compose.yml'],
|
||||
));
|
||||
|
||||
expect($host->build_pack)->toBe('railpack')
|
||||
->and($host->suggestedBuildPack)->toBeNull()
|
||||
->and($host->detectedDockerComposeFiles)->toBe(['examples/demo/docker-compose.yml']);
|
||||
});
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
use App\Data\RepositoryDetectionResult;
|
||||
use App\Enums\BuildPackTypes;
|
||||
|
||||
test('empty result returns correct defaults', function () {
|
||||
$result = RepositoryDetectionResult::none();
|
||||
|
||||
expect($result->dockerfiles)->toBe([])
|
||||
->and($result->dockerComposeFiles)->toBe([])
|
||||
->and($result->envFiles)->toBe([])
|
||||
->and($result->dockerfilePorts)->toBe([]);
|
||||
});
|
||||
|
||||
test('getSuggestedBuildPack returns dockercompose when compose files found', function () {
|
||||
$result = new RepositoryDetectionResult(
|
||||
dockerfiles: ['Dockerfile'],
|
||||
dockerComposeFiles: ['docker-compose.yml'],
|
||||
);
|
||||
|
||||
expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE);
|
||||
});
|
||||
|
||||
test('getSuggestedBuildPack returns dockerfile when only dockerfiles found', function () {
|
||||
$result = new RepositoryDetectionResult(
|
||||
dockerfiles: ['Dockerfile'],
|
||||
);
|
||||
|
||||
expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE);
|
||||
});
|
||||
|
||||
test('getSuggestedBuildPack returns null when nothing found', function () {
|
||||
$result = RepositoryDetectionResult::none();
|
||||
|
||||
expect($result->getSuggestedBuildPack())->toBeNull();
|
||||
});
|
||||
|
||||
test('getSuggestedBuildPack ignores files in subdirectories', function () {
|
||||
$result = new RepositoryDetectionResult(
|
||||
dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'],
|
||||
dockerComposeFiles: ['examples/demo/docker-compose.yml'],
|
||||
);
|
||||
|
||||
expect($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE);
|
||||
|
||||
$nestedOnly = new RepositoryDetectionResult(
|
||||
dockerfiles: ['apps/api/Dockerfile'],
|
||||
dockerComposeFiles: ['examples/demo/docker-compose.yml'],
|
||||
);
|
||||
|
||||
expect($nestedOnly->getSuggestedBuildPack())->toBeNull();
|
||||
});
|
||||
|
||||
test('hasDockerfile returns true when dockerfiles present', function () {
|
||||
$result = new RepositoryDetectionResult(
|
||||
dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'],
|
||||
);
|
||||
|
||||
expect($result->hasDockerfile())->toBeTrue();
|
||||
});
|
||||
|
||||
test('hasDockerfile returns false when no dockerfiles', function () {
|
||||
$result = RepositoryDetectionResult::none();
|
||||
|
||||
expect($result->hasDockerfile())->toBeFalse();
|
||||
});
|
||||
|
||||
test('hasDockerCompose returns true when compose files present', function () {
|
||||
$result = new RepositoryDetectionResult(
|
||||
dockerComposeFiles: ['docker-compose.yml'],
|
||||
);
|
||||
|
||||
expect($result->hasDockerCompose())->toBeTrue();
|
||||
});
|
||||
|
||||
test('hasDockerCompose returns false when no compose files', function () {
|
||||
$result = RepositoryDetectionResult::none();
|
||||
|
||||
expect($result->hasDockerCompose())->toBeFalse();
|
||||
});
|
||||
|
||||
test('dockerfilePorts stores port mapping correctly', function () {
|
||||
$result = new RepositoryDetectionResult(
|
||||
dockerfiles: ['Dockerfile', 'apps/api/Dockerfile'],
|
||||
dockerfilePorts: ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080],
|
||||
);
|
||||
|
||||
expect($result->dockerfilePorts)->toBe(['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080])
|
||||
->and($result->dockerfilePorts['Dockerfile'])->toBe(3000)
|
||||
->and($result->dockerfilePorts['apps/api/Dockerfile'])->toBe(8080);
|
||||
});
|
||||
|
||||
test('hasEnvFiles returns true when env files present', function () {
|
||||
$result = new RepositoryDetectionResult(
|
||||
envFiles: ['.env.example' => 'APP_KEY=secret'],
|
||||
);
|
||||
|
||||
expect($result->hasEnvFiles())->toBeTrue();
|
||||
});
|
||||
|
||||
test('hasEnvFiles returns false when no env files', function () {
|
||||
$result = RepositoryDetectionResult::none();
|
||||
|
||||
expect($result->hasEnvFiles())->toBeFalse();
|
||||
});
|
||||
|
||||
test('envFiles stores multiple files with content', function () {
|
||||
$result = new RepositoryDetectionResult(
|
||||
envFiles: [
|
||||
'.env.example' => 'APP_KEY=secret',
|
||||
'.env.sample' => 'DB_HOST=localhost',
|
||||
'.env.dist' => null,
|
||||
],
|
||||
);
|
||||
|
||||
expect($result->envFiles)->toHaveCount(3)
|
||||
->and($result->envFiles['.env.example'])->toBe('APP_KEY=secret')
|
||||
->and($result->envFiles['.env.sample'])->toBe('DB_HOST=localhost')
|
||||
->and($result->envFiles['.env.dist'])->toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
use App\Enums\BuildPackTypes;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationSetting;
|
||||
use App\Models\Server;
|
||||
use App\Services\RepositoryDetector;
|
||||
use Symfony\Component\Process\Process;
|
||||
|
||||
function repositoryDetector(string $repository = 'https://github.com/test/repo', string $baseDirectory = '/'): RepositoryDetector
|
||||
{
|
||||
$application = (new Application)->forceFill(['git_repository' => $repository, 'git_branch' => 'main', 'git_commit_sha' => 'HEAD']);
|
||||
$application->setRelation('settings', (new ApplicationSetting)->forceFill(['is_git_shallow_clone_enabled' => true, 'is_git_submodules_enabled' => false]));
|
||||
$application->setRelation('source', null);
|
||||
$application->setRelation('private_key', null);
|
||||
|
||||
return new RepositoryDetector($application, $baseDirectory, new Server);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs the scan commands on this machine against a local Git repository, optionally after the
|
||||
* non-root sudo parser (with `sudo` removed, so the test does not need root).
|
||||
*/
|
||||
function runRepositoryScan(RepositoryDetector $detector, string $uuid, bool $throughSudoParser = false): Process
|
||||
{
|
||||
$commands = (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, $uuid);
|
||||
if ($throughSudoParser) {
|
||||
$server = Mockery::mock(Server::class)->makePartial();
|
||||
$server->shouldReceive('getAttribute')->with('user')->andReturn('ubuntu');
|
||||
$commands = collect(parseCommandsByLineForSudo($commands, $server))
|
||||
->map(fn (string $line) => preg_replace('/(^|\s)sudo /', '$1', $line))
|
||||
->map(fn (string $line) => preg_replace('/ && chown .*$/', '', $line));
|
||||
}
|
||||
$process = Process::fromShellCommandline("set -e\n".$commands->implode("\n"));
|
||||
$process->run();
|
||||
|
||||
return $process;
|
||||
}
|
||||
|
||||
beforeEach(function () {
|
||||
$this->detector = repositoryDetector();
|
||||
$this->parseOutputMethod = new ReflectionMethod($this->detector, 'parseOutput');
|
||||
});
|
||||
|
||||
test('parseOutput handles complete detection output', function () {
|
||||
$output = json_encode([
|
||||
'dockerfiles' => ['Dockerfile', 'apps/api/Dockerfile'],
|
||||
'dockerComposeFiles' => ['docker-compose.yml'],
|
||||
'envFiles' => ['.env.example' => "APP_NAME=MyApp\nAPP_ENV=production\nDB_HOST=localhost\nDB_PORT=5432"],
|
||||
'dockerfilePorts' => ['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080],
|
||||
]);
|
||||
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, $output);
|
||||
|
||||
expect($result->dockerfiles)->toBe(['Dockerfile', 'apps/api/Dockerfile'])
|
||||
->and($result->dockerComposeFiles)->toBe(['docker-compose.yml'])
|
||||
->and($result->envFiles)->toHaveKey('.env.example')
|
||||
->and($result->envFiles['.env.example'])->toContain('APP_NAME=MyApp')
|
||||
->and($result->dockerfilePorts)->toBe(['Dockerfile' => 3000, 'apps/api/Dockerfile' => 8080])
|
||||
->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE);
|
||||
});
|
||||
|
||||
test('parseOutput handles empty repository', function () {
|
||||
$output = json_encode([
|
||||
'dockerfiles' => [],
|
||||
'dockerComposeFiles' => [],
|
||||
'envFiles' => (object) [],
|
||||
'dockerfilePorts' => (object) [],
|
||||
]);
|
||||
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, $output);
|
||||
|
||||
expect($result->dockerfiles)->toBe([])
|
||||
->and($result->dockerComposeFiles)->toBe([])
|
||||
->and($result->envFiles)->toBe([])
|
||||
->and($result->dockerfilePorts)->toBe([])
|
||||
->and($result->getSuggestedBuildPack())->toBeNull();
|
||||
});
|
||||
|
||||
test('parseOutput handles dockerfile without EXPOSE', function () {
|
||||
$output = json_encode([
|
||||
'dockerfiles' => ['Dockerfile'],
|
||||
'dockerComposeFiles' => [],
|
||||
'envFiles' => (object) [],
|
||||
'dockerfilePorts' => ['Dockerfile' => null],
|
||||
]);
|
||||
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, $output);
|
||||
|
||||
expect($result->dockerfiles)->toBe(['Dockerfile'])
|
||||
->and($result->dockerfilePorts)->toBe(['Dockerfile' => null])
|
||||
->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERFILE);
|
||||
});
|
||||
|
||||
test('parseOutput handles only env files without dockerfiles', function () {
|
||||
$output = json_encode([
|
||||
'dockerfiles' => [],
|
||||
'dockerComposeFiles' => [],
|
||||
'envFiles' => ['.env.example' => "SECRET_KEY=changeme\nDATABASE_URL=postgres://localhost/db"],
|
||||
'dockerfilePorts' => (object) [],
|
||||
]);
|
||||
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, $output);
|
||||
|
||||
expect($result->dockerfiles)->toBe([])
|
||||
->and($result->envFiles)->toHaveKey('.env.example')
|
||||
->and($result->envFiles['.env.example'])->toContain('SECRET_KEY=changeme')
|
||||
->and($result->envFiles['.env.example'])->toContain('DATABASE_URL=postgres://localhost/db')
|
||||
->and($result->getSuggestedBuildPack())->toBeNull();
|
||||
});
|
||||
|
||||
test('parseOutput handles multiple compose files', function () {
|
||||
$output = json_encode([
|
||||
'dockerfiles' => [],
|
||||
'dockerComposeFiles' => ['docker-compose.yml', 'compose.yaml'],
|
||||
'envFiles' => (object) [],
|
||||
'dockerfilePorts' => (object) [],
|
||||
]);
|
||||
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, $output);
|
||||
|
||||
expect($result->dockerComposeFiles)->toBe(['docker-compose.yml', 'compose.yaml'])
|
||||
->and($result->getSuggestedBuildPack())->toBe(BuildPackTypes::DOCKERCOMPOSE);
|
||||
});
|
||||
|
||||
test('parseOutput puts the file nearest to the base directory first', function () {
|
||||
$output = json_encode([
|
||||
'dockerfiles' => ['apps/api/Dockerfile', 'Dockerfile.dev', 'apps/web/Dockerfile', 'Dockerfile'],
|
||||
'dockerComposeFiles' => ['examples/demo/compose.yaml', 'deploy/docker-compose.yml', 'compose.yaml'],
|
||||
'envFiles' => (object) [],
|
||||
'dockerfilePorts' => (object) [],
|
||||
]);
|
||||
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, $output);
|
||||
|
||||
expect($result->dockerfiles)->toBe(['Dockerfile', 'Dockerfile.dev', 'apps/api/Dockerfile', 'apps/web/Dockerfile'])
|
||||
->and($result->dockerComposeFiles)->toBe(['compose.yaml', 'deploy/docker-compose.yml', 'examples/demo/compose.yaml']);
|
||||
});
|
||||
|
||||
test('parseOutput handles multiple env files', function () {
|
||||
$output = json_encode([
|
||||
'dockerfiles' => [],
|
||||
'dockerComposeFiles' => [],
|
||||
'envFiles' => [
|
||||
'.env.example' => "APP_KEY=base64:abc\nAPP_ENV=local",
|
||||
'.env.sample' => "DB_HOST=127.0.0.1\nDB_PORT=5432",
|
||||
'.env.dist' => 'REDIS_HOST=localhost',
|
||||
],
|
||||
'dockerfilePorts' => (object) [],
|
||||
]);
|
||||
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, $output);
|
||||
|
||||
expect($result->envFiles)->toHaveCount(3)
|
||||
->and($result->envFiles['.env.example'])->toContain('APP_KEY=base64:abc')
|
||||
->and($result->envFiles['.env.sample'])->toContain('DB_HOST=127.0.0.1')
|
||||
->and($result->envFiles['.env.dist'])->toContain('REDIS_HOST=localhost')
|
||||
->and($result->hasEnvFiles())->toBeTrue();
|
||||
});
|
||||
|
||||
test('parseOutput handles string port values from JSON', function () {
|
||||
$output = json_encode([
|
||||
'dockerfiles' => ['Dockerfile'],
|
||||
'dockerComposeFiles' => [],
|
||||
'envFiles' => (object) [],
|
||||
'dockerfilePorts' => ['Dockerfile' => '3000'],
|
||||
]);
|
||||
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, $output);
|
||||
|
||||
expect($result->dockerfilePorts)->toBe(['Dockerfile' => 3000])
|
||||
->and($result->dockerfilePorts['Dockerfile'])->toBeInt();
|
||||
});
|
||||
|
||||
test('parseOutput returns none for invalid JSON', function () {
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, 'not valid json');
|
||||
|
||||
expect($result->dockerfiles)->toBe([])
|
||||
->and($result->dockerComposeFiles)->toBe([])
|
||||
->and($result->envFiles)->toBe([])
|
||||
->and($result->dockerfilePorts)->toBe([]);
|
||||
});
|
||||
|
||||
test('scanner rejects traversal before remote execution', function (string $baseDirectory) {
|
||||
$detector = repositoryDetector(baseDirectory: $baseDirectory);
|
||||
expect(fn () => (new ReflectionMethod($detector, 'scanCommands'))->invoke($detector, 'scan-test'))->toThrow(RuntimeException::class);
|
||||
})->with(['/../outside', '/app/../..', '/./app', '/app;id']);
|
||||
|
||||
test('scanner reads only regular Git blobs and honors build directory', function (bool $throughSudoParser) {
|
||||
$repo = sys_get_temp_dir().'/scan-fixture-'.bin2hex(random_bytes(8));
|
||||
$uuid = 'scan-test-'.bin2hex(random_bytes(8));
|
||||
mkdir($repo);
|
||||
mkdir($repo.'/app');
|
||||
mkdir($repo.'/app/services');
|
||||
file_put_contents($repo.'/Dockerfile', "FROM scratch\nEXPOSE 9000\n");
|
||||
file_put_contents($repo.'/app/Dockerfile', "FROM scratch\r\n expose 8080/tcp\r\n");
|
||||
file_put_contents($repo.'/app/services/Dockerfile.worker', "FROM scratch\nEXPOSE \${PORT}\n");
|
||||
file_put_contents($repo.'/app/compose.yaml', 'services: {}');
|
||||
file_put_contents($repo.'/app/.env.sample', "SAFE=value\nQUOTE='it''s'");
|
||||
file_put_contents($repo.'/app/services/.env.example', 'NESTED=ignored');
|
||||
file_put_contents($repo.'/app/my dockerfile notes.txt', 'not a Dockerfile');
|
||||
file_put_contents($repo.'/secret', 'HOST_SECRET=do-not-read');
|
||||
symlink($repo.'/secret', $repo.'/app/.env.example');
|
||||
$git = 'git -C '.escapeshellarg($repo);
|
||||
Process::fromShellCommandline("{$git} init -q -b main && {$git} add . && {$git} -c user.name=Test -c user.email=test@example.com commit -qm fixture")->mustRun();
|
||||
try {
|
||||
$detector = repositoryDetector($repo, '/app');
|
||||
$process = runRepositoryScan($detector, $uuid, $throughSudoParser);
|
||||
expect($process->isSuccessful())->toBeTrue($process->getErrorOutput());
|
||||
$result = (new ReflectionMethod($detector, 'parseOutput'))->invoke($detector, $process->getOutput());
|
||||
expect($result->dockerfiles)->toBe(['Dockerfile', 'services/Dockerfile.worker'])
|
||||
->and($result->dockerComposeFiles)->toBe(['compose.yaml'])
|
||||
->and($result->envFiles)->toBe(['.env.sample' => "SAFE=value\nQUOTE='it''s'"])
|
||||
->and($result->dockerfilePorts)->toBe(['Dockerfile' => 8080, 'services/Dockerfile.worker' => null]);
|
||||
|
||||
$failure = runRepositoryScan(repositoryDetector($repo.'-missing'), $uuid.'-missing', $throughSudoParser);
|
||||
expect($failure->isSuccessful())->toBeFalse();
|
||||
} finally {
|
||||
Process::fromShellCommandline('rm -rf -- '.escapeshellarg($repo).' /tmp/'.$uuid.' /tmp/'.$uuid.'-missing')->mustRun();
|
||||
}
|
||||
})->with(['root server' => false, 'non-root server' => true]);
|
||||
|
||||
test('invalid port values are not suggested', function (mixed $port) {
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, json_encode(['dockerfilePorts' => ['Dockerfile' => $port]]));
|
||||
expect($result->dockerfilePorts['Dockerfile'])->toBeNull();
|
||||
})->with([0, -1, 65536, '3.5', '3e3', true]);
|
||||
|
||||
test('malformed scan collections fall back safely', function () {
|
||||
$result = $this->parseOutputMethod->invoke($this->detector, '{"dockerfiles":"Dockerfile"}');
|
||||
expect($result->dockerfiles)->toBe([]);
|
||||
});
|
||||
Reference in new issue
Block a user