feat: smart scan for git project creation (#8502)

Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com>
This commit is contained in:
Aditya TripathiandAndras Bacsai authored and GitHub committed 2026-10-08 15:00:44 +02:00
1 parent 73c4dbb00e
commit 51641c05b2
19 files changed
+1362 -16

No files matched your search

+69
View File
@@ -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,
+12 -2
View File
@@ -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}");
+129
View File
@@ -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;
}
}
+273
View File
@@ -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),
+18
View File
@@ -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');
});
+152
View File
@@ -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();
});
+231
View File
@@ -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([]);
});