Compare commits

..

10 Commits

21 changed files with 2278 additions and 5 deletions

View File

@@ -14,6 +14,9 @@ composer_version: "2"
web_environment: [] web_environment: []
corepack_enable: false corepack_enable: false
omit_containers: [db] omit_containers: [db]
hooks:
pre-start:
- exec-host: ddev dotenv set .ddev/.env --docker-dotenv-vars="$(docker compose -f .ddev/.ddev-docker-compose-full.yaml config | awk '/^[[:space:]]{2}web:$/ {inweb=1; next} inweb && /^[[:space:]]{4}environment:$/ {inenv=1; next} inenv && /^[[:space:]]{6}[A-Za-z0-9_]+:/ {gsub(/^[[:space:]]+|:$/, "", $1); print $1; next} inenv && /^[[:space:]]{4}[^[:space:]]/ {inenv=0} inweb && /^[[:space:]]{2}[^[:space:]]/ && !/^[[:space:]]{4}/ {inweb=0}' | sort -u | paste -sd, -)"
# Key features of DDEV's config.yaml: # Key features of DDEV's config.yaml:

View File

@@ -0,0 +1,4 @@
services:
web:
environment:
- PHP_IDE_CONFIG=serverName=orchestar.ddev.site

6
.idea/copyright/Copyright.xml generated Normal file
View File

@@ -0,0 +1,6 @@
<component name="CopyrightManager">
<copyright>
<option name="notice" value="&amp;#36;file.fileName &amp;#36;file.lastModified.format(&quot;Y-MM-d&quot;) &amp;#36;username&#10;&#10;Copyright (c) &amp;#36;originalComment.match(&quot;Copyright \(c\) (\d+)&quot;, 1, &quot;-&quot;, &quot;&amp;#36;today.year&quot;)&amp;#36;today.year Thomas Schneider &lt;thomas@inter-mundos.de&gt;&#10;Alle Rechte vorbehalten." />
<option name="myName" value="Copyright" />
</copyright>
</component>

7
.idea/copyright/profiles_settings.xml generated Normal file
View File

@@ -0,0 +1,7 @@
<component name="CopyrightManager">
<settings default="Copyright">
<module2copyright>
<element module="Project Files" copyright="Copyright" />
</module2copyright>
</settings>
</component>

View File

@@ -10,6 +10,7 @@
"doctrine/doctrine-bundle": "^3.2", "doctrine/doctrine-bundle": "^3.2",
"doctrine/doctrine-migrations-bundle": "^4.0", "doctrine/doctrine-migrations-bundle": "^4.0",
"doctrine/orm": "^3.6", "doctrine/orm": "^3.6",
"league/uri": "^7.0",
"phpdocumentor/reflection-docblock": "^6.0", "phpdocumentor/reflection-docblock": "^6.0",
"phpstan/phpdoc-parser": "^2.3", "phpstan/phpdoc-parser": "^2.3",
"sentry/sentry-symfony": "^5.9", "sentry/sentry-symfony": "^5.9",
@@ -20,6 +21,8 @@
"symfony/doctrine-messenger": "8.0.*", "symfony/doctrine-messenger": "8.0.*",
"symfony/dotenv": "8.0.*", "symfony/dotenv": "8.0.*",
"symfony/expression-language": "8.0.*", "symfony/expression-language": "8.0.*",
"symfony/filesystem": "8.0.*",
"symfony/finder": "8.0.*",
"symfony/flex": "^2", "symfony/flex": "^2",
"symfony/form": "8.0.*", "symfony/form": "8.0.*",
"symfony/framework-bundle": "8.0.*", "symfony/framework-bundle": "8.0.*",
@@ -27,7 +30,7 @@
"symfony/intl": "8.0.*", "symfony/intl": "8.0.*",
"symfony/mailer": "8.0.*", "symfony/mailer": "8.0.*",
"symfony/mime": "8.0.*", "symfony/mime": "8.0.*",
"symfony/monolog-bundle": "^3.0|^4.0", "symfony/monolog-bundle": "^4.0",
"symfony/notifier": "8.0.*", "symfony/notifier": "8.0.*",
"symfony/process": "8.0.*", "symfony/process": "8.0.*",
"symfony/property-access": "8.0.*", "symfony/property-access": "8.0.*",
@@ -44,7 +47,8 @@
"symfony/web-link": "8.0.*", "symfony/web-link": "8.0.*",
"symfony/yaml": "8.0.*", "symfony/yaml": "8.0.*",
"twig/extra-bundle": "^2.12|^3.0", "twig/extra-bundle": "^2.12|^3.0",
"twig/twig": "^2.12|^3.0" "twig/twig": "^2.12|^3.0",
"vlucas/phpdotenv": "^5.6"
}, },
"config": { "config": {
"allow-plugins": { "allow-plugins": {

1018
composer.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -7,12 +7,15 @@
# Put parameters here that don't need to change on each machine where the app is deployed # Put parameters here that don't need to change on each machine where the app is deployed
# https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration # https://symfony.com/doc/current/best_practices.html#use-parameters-for-application-configuration
parameters: parameters:
app.data_dir: '%kernel.project_dir%/var/data'
services: services:
# default configuration for services in *this* file # default configuration for services in *this* file
_defaults: _defaults:
autowire: true # Automatically injects dependencies in your services. autowire: true # Automatically injects dependencies in your services.
autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. autoconfigure: true # Automatically registers your services as commands, event subscribers, etc.
bind:
string $dataDir: '%app.data_dir%'
# makes classes in src/ available to be used as services # makes classes in src/ available to be used as services
# this creates a service per class whose id is the fully-qualified class name # this creates a service per class whose id is the fully-qualified class name

View File

@@ -0,0 +1,21 @@
<?php
/*
* DataFromArray.php 2026-03-16 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
declare(strict_types=1);
namespace App\Attributes\Mapping;
use Attribute;
#[Attribute(Attribute::TARGET_PROPERTY)]
final readonly class DataFromArray
{
public function __construct(
public string|null $key,
){}
}

View File

74
src/Entity/Project.php Normal file
View File

@@ -0,0 +1,74 @@
<?php
/*
* Project.php 2026-03-20 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Entity;
use App\Attributes\Mapping\DataFromArray;
use App\Service\Deployment\DeploymentInterface;
use ReflectionClass;
use ReflectionException;
class Project
{
#[DataFromArray(key: 'name')]
public readonly string $name;
#[DataFromArray(key: 'deployment')]
public readonly array $deployment;
protected DeploymentInterface $deploymentService;
public function __construct(
array $data,
public string $projectDir,
public string $deployedVersion,
\Closure $deploymentServiceResolver,
){
$this->autoMapData($data);
$this->deploymentService = ($deploymentServiceResolver)()->init($this);
}
public function deploy(string $version = '', string $step = ''): void
{
$this->deploymentService->handle($this, $version, $step);
}
/**
* Automatically maps data from the provided array to object properties
* based on attributes defined in the class.
*
* @param array $data The input data array containing key-value pairs to map.
*/
protected function autoMapData(array $data): void
{
try
{
$class = new ReflectionClass($this);
foreach($class->getProperties() as $property)
{
foreach($property->getAttributes() as $attribute)
{
if($attribute->getName() === DataFromArray::class)
{
$attributeArguments = $attribute->getArguments();
$this->{$property->getName()} = $data[$attributeArguments['key']];
}
}
}
}
catch(ReflectionException $e)
{
}
}
}

View File

View File

@@ -0,0 +1,57 @@
<?php
/*
* ProjectRepository.php 2026-03-15 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Repository;
use App\Entity\Project;
use App\Service\Deployment\DeploymentServiceResolver;
use App\Service\ProjectConfigDirService;
use Generator;
use Symfony\Component\DependencyInjection\Attribute\AutowireServiceClosure;
use Symfony\Component\Yaml\Yaml;
class ProjectRepository
{
/** @var Project[] */
private array $projects;
public function __construct(
protected ProjectConfigDirService $configDirService,
#[AutowireServiceClosure(DeploymentServiceResolver::class)] \Closure $deploymentServiceResolver,
){
foreach($this->configDirService->getProjectDirContents() as $projectFile)
{
$deployedVersion = $this->configDirService->getDeployedVersion($projectFile->getPath());
$this->projects[$projectFile->getRelativePath()] = new Project(
data: Yaml::parseFile($projectFile->getRealPath()),
projectDir: $projectFile->getPath(),
deployedVersion: $deployedVersion,
deploymentServiceResolver: $deploymentServiceResolver,
);
}
}
public function getAll(): Generator
{
yield from $this->projects;
}
public function findById($id): Project|null
{
return $this->projects[$id] ?? null;
}
public function exists(string $id): bool
{
return array_any($this->projects, fn($project) => $project['id'] === $id);
}
}

View File

@@ -0,0 +1,26 @@
<?php
/*
* DeploymentInterface.php 2026-03-20 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Service\Deployment;
use App\Entity\Project;
interface DeploymentInterface
{
public function supports(): string;
public function init(Project $project): void;
public function handle(Project $project, string $version = '', string $step = ''): void;
public function fetch(Project $project): void;
public function deploy(Project $project, string $version): void;
public function cleanup(Project $project): void;
}

View File

@@ -0,0 +1,72 @@
<?php
/*
* DeploymentService.php 2026-03-23 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Service\Deployment;
use App\Entity\Project;
use App\Service\Docker\DockerCompose;
use App\Service\ProjectConfigDirService;
use App\Traits\LoggerTrait;
use Exception;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Contracts\Service\Attribute\Required;
#[AutoconfigureTag('app.deployment_service')]
abstract class DeploymentService implements DeploymentInterface
{
use LoggerTrait;
protected ProjectConfigDirService $configDirService;
protected string $filesDir = 'files.deploy';
#[Required]
public function setDependencies(
ProjectConfigDirService $configDirService
): void
{
$this->configDirService = $configDirService;
}
public function deploy(Project $project, string $version): void
{
new DockerCompose()
->setName($project->name)
->setWorkingDir($project->projectDir)
->setComposeFiles([$this->filesDir . '/compose.yaml', $this->filesDir . '/compose.prod.yaml'])
->daemonize(true)
->up(['--build']);
}
public function cleanup(Project $project): void
{
$this->clearDeploymentFiles($project);
}
protected function clearDeploymentFiles(Project $project): void
{
$filesystem = new Filesystem();
if($filesystem->exists($project->projectDir . DIRECTORY_SEPARATOR . $this->filesDir))
{
$this->logger->info('Removing working tree');
try
{
$filesystem->remove($project->projectDir . DIRECTORY_SEPARATOR . $this->filesDir);
}
catch(Exception $e)
{
$this->logger->error('Failed to remove deployment files: ' . $e->getMessage());
}
}
}
}

View File

@@ -0,0 +1,40 @@
<?php
/*
* DeploymentServiceResolver.php 2026-03-15 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Service\Deployment;
use App\Entity\Project;
use Symfony\Component\DependencyInjection\Attribute\AutowireIterator;
class DeploymentServiceResolver
{
public function __construct(
#[AutowireIterator('app.deployment_service')] protected iterable $deploymentServices,
){}
public function init(Project $project): DeploymentInterface
{
if(empty($project->deployment['type']))
{
throw new \InvalidArgumentException('Deployment type is not set');
}
foreach($this->deploymentServices as $deploymentService)
{
if ($deploymentService->supports() === $project->deployment['type'])
{
$deploymentService->init($project);
return $deploymentService;
}
}
throw new \InvalidArgumentException('Deployment type is not supported');
}
}

View File

@@ -0,0 +1,371 @@
<?php
/*
* DockerCompose.php 2026-03-27 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Service\Docker;
use App\Service\Process\CleanProcess;
use Dotenv\Dotenv;
use Exception;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Process\Process;
class DockerCompose
{
protected string $composeCmd = 'compose';
protected float $startCommandTimeout = 600;
protected string $name = '';
protected bool $daemonize = false;
protected string $dockerHost = '';
protected string $workingDir = '';
protected array $composeFiles = [];
protected array $envFiles = [];
protected array $inlineEnvFiles = [];
/**
* @todo Fehlerüberprüfung /-ausgabe optimieren
*/
public function up(array $optionalArgs = []): void
{
$process = $this->process('up', $optionalArgs);
$process->run(function ($type, $buffer): void
{
echo $buffer;
});
// if(!$process->isSuccessful())
// {
// throw CouldNotStartDockerContainer::processFailed($this, $process);
// }
$error = $process->getErrorOutput();
$dockerIdentifier = trim($process->getOutput());
}
/**
* Generates a process instance for the given command.
*
* @param string $cmd The command to execute.
* @param array $optionalArgs Optional arguments to be appended to the command.
*
* @return Process The configured process instance.
*
* @todo Improve error handling and validation of environment variables.
* @todo Evaluate potential performance issues with environment loading and inline parsing.
*/
protected function process(string $cmd, array $optionalArgs = []): Process
{
$env = [...$this->loadDotEnv(), ...$this->parseInlineEnvFiles()];
if(!empty($env['DOCKER_HOST']))
{
$this->dockerHost = $env['DOCKER_HOST'];
}
return CleanProcess::fromShellCommandline(
command: implode(' ', [...$this->getBaseCommand(), $cmd, ...$this->getExtraOptions($optionalArgs)]),
cwd: $this->workingDir,
env: $env,
timeout: $this->startCommandTimeout,
);
}
/**
* Constructs and returns the base command array for the Docker Compose operations.
*
* @return array The assembled base command array, including the base compose command
* and additional Docker options.
*
* @todo Evaluate potential optimizations in command composition and handling.
*/
protected function getBaseCommand(): array
{
return [
...explode(' ', $this->composeCmd),
...$this->getExtraDockerOptions(),
];
}
/**
* @todo Review the logic for constructing extra Docker options, ensuring all possible options are correctly appended.
* @todo Validate and sanitize the inputs for docker host, name, compose files, and environment files if necessary.
* @todo Consider handling potential edge cases where input values might contain unexpected characters or formats.
*/
protected function getExtraDockerOptions(): array
{
$extraDockerOptions = [];
if($this->dockerHost !== '')
{
$extraDockerOptions[] = "-H {$this->dockerHost}";
}
if($this->name !== '')
{
$extraDockerOptions[] = "-p {$this->name}";
}
if($this->composeFiles)
{
foreach($this->composeFiles as $file)
{
$extraDockerOptions[] = "--file {$file}";
}
}
if($this->envFiles)
{
foreach($this->envFiles as $file)
{
$extraDockerOptions[] = "--env-file {$file}";
}
}
return $extraDockerOptions;
}
/**
* Retrieves additional options for the current process.
*
* If the application is running in daemon mode and the '-d' option
* is not already set, it will be appended to the resulting options array.
*
* @param array $optionalArgs Optional arguments to customize the options.
* @return array Merged array of optional arguments and inferred additional options.
*/
protected function getExtraOptions(array $optionalArgs = []): array
{
$extraOptions = [...$optionalArgs];
if($this->daemonize && !in_array('-d', $extraOptions, true))
{
$extraOptions[] = '-d';
}
return $extraOptions;
}
/**
* Sets the name property of the object.
*
* This method assigns the provided name to the object's internal property
* and returns the current instance for method chaining.
*
* @param string $name The name to set.
* @return self The current instance of the object.
*/
public function setName(string $name): self
{
$this->name = $name;
return $this;
}
/**
* Sets the working directory.
*
* This method assigns the provided directory path to the internal
* working directory property and returns the current instance for
* method chaining.
*
* @param string $workingDir The path to set as the working directory.
* @return self The current instance for fluent interface usage.
*/
public function setWorkingDir(string $workingDir): self
{
$this->workingDir = $workingDir;
return $this;
}
/**
* Sets the timeout value for the start command.
*
* This method assigns a specified timeout value to the start command
* and returns the current instance for method chaining.
*
* @param float $startCommandTimeout The timeout duration in seconds.
* @return self The current instance with the updated timeout value.
*/
public function setStartCommandTimeout(float $startCommandTimeout): self
{
$this->startCommandTimeout = $startCommandTimeout;
return $this;
}
/**
* Sets the Docker host configuration.
*
* This method assigns the provided Docker host value to the corresponding property
* and returns the current instance for method chaining.
*
* @param string $dockerHost The Docker host address to be set.
*
* @return self The current instance of the class.
*/
public function setDockerHost(string $dockerHost): self
{
$this->dockerHost = $dockerHost;
return $this;
}
/**
* Sets the daemonize mode for the application.
*
* This method allows enabling or disabling the daemon mode
* by setting a boolean value. The current instance of the class
* is returned to allow method chaining.
*
* @param bool $daemonize Indicates whether to enable or disable daemon mode.
* @return self The current instance of the class.
*/
public function daemonize(bool $daemonize): self
{
$this->daemonize = $daemonize;
return $this;
}
/**
* Sets and merges Docker Compose files.
*
* This method updates the list of Docker Compose files by merging the
* provided array of files into the existing list. The operation ensures
* that the provided files are appended to the current set of files.
*
* @param array $composeFiles The array of Docker Compose file paths to add.
* @return self Returns the current instance for method chaining.
*/
public function setComposeFiles(array $composeFiles): self
{
$this->composeFiles = [...$this->composeFiles, ...$composeFiles];
return $this;
}
/**
* Sets the environment files and merges them with the existing ones.
*
* This method appends the provided array of environment file paths to the
* existing list of files stored in the class. The updated list of files
* is then returned for further operations.
*
* @param array $envFiles An array of environment file paths to be added.
* @return self The current instance for method chaining.
*/
public function setEnvFiles(array $envFiles): self
{
$this->envFiles = [...$this->envFiles, ...$envFiles];
return $this;
}
/**
* Sets the inline environment files and appends them to the existing list.
*
* This method updates the internal list of inline environment files by
* appending the provided array of file paths to the current list. It allows
* for dynamically adding environment file paths to be processed.
*
* @param array $envFiles An array of environment file paths to be added.
* @return self The current instance for method chaining.
*/
public function setInlineEnvFiles(array $envFiles): self
{
$this->inlineEnvFiles = [...$this->inlineEnvFiles, ...$envFiles];
return $this;
}
/**
* Retrieves the list of inline environment files.
*
* This method returns an array containing the names of the environment
* files that are processed inline. These files typically contain environment
* variables that can be used by the application.
*
* @return array The list of inline environment file names.
*/
public function getInlineEnvFiles(): array
{
return $this->inlineEnvFiles;
}
/**
* Parses the inline environment files and merges their contents.
*
* This method iterates through a list of inline environment files,
* reads their content, and parses them into an associative array.
* Any files that cannot be read or parsed will be skipped. The resulting
* data from all parsed files is combined into a single array and returned.
*
* @return array The merged environment variables from the parsed files.
*/
protected function parseInlineEnvFiles(): array
{
$env = [];
$filesystem = new Filesystem();
foreach ($this->inlineEnvFiles as $file)
{
try
{
if(!empty($envFromFile = Dotenv::parse($filesystem->readFile($file))))
{
$env = [...$env, ...$envFromFile];
}
}
catch(Exception $e)
{
continue;
}
}
return $env;
}
/**
* Loads environment variables from the application's working directory.
*
* This method uses the Dotenv library to parse and retrieve environment
* variables defined in the designated working directory. If an error occurs
* during the loading process, an empty array is returned.
*
* @return array The environment variables loaded from the working directory.
*/
protected function loadDotEnv(): array
{
try
{
return Dotenv::createArrayBacked($this->workingDir)->load();
}
catch(Exception $e)
{
return [];
}
}
}

View File

@@ -0,0 +1,154 @@
<?php
/*
* GitDeployment.php 2026-03-27 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Service\Git;
use App\Entity\Project;
use App\Service\Deployment\DeploymentService;
use App\Service\ProjectConfigDirService;
use App\Traits\LoggerTrait;
use Symfony\Component\DependencyInjection\Attribute\AutoconfigureTag;
#[AutoconfigureTag('app.deployment_service', ['git'])]
class GitDeployment extends DeploymentService
{
use LoggerTrait;
const string GIT_DIR = 'repo.git';
public function __construct(
protected GitService $git, private readonly ProjectConfigDirService $projectConfigDirService,
){}
public function supports(): string
{
return 'git';
}
public function init(Project $project): void
{
$this->git->setWorkingDir($project->projectDir);
}
/**
* Handles the deployment process for the specified project by fetching the required
* resources and deploying the appropriate version.
*
* This method first fetches the necessary project resources. Then, it delegates
* the deployment process to the deployment method, optionally using the provided
* version and step parameters.
*
* @param Project $project The project to handle.
* @param string $version An optional specific version to deploy. Defaults to an empty string.
* @param string $step An optional step parameter used during the handling process. Defaults to an empty string.
*
* @return void
*/
public function handle(Project $project, string $version = '', string $step = ''): void
{
$this->fetch($project);
$this->deploy($project, $version);
}
/**
* Fetches the latest updates for the specified project's repository.
* Depending on the state of the repository directory, either a fetch or
* a full clone operation is performed. Supports authenticated access to
* repositories when a URL with HTTP is provided.
*
* If the repository is already cloned, this method performs a fetch operation
* to synchronize the local repository. Otherwise, it clones the repository in
* mirror mode to the designated directory.
*
* During the process, the deployment step is updated to reflect the current
* stage of the fetch operation for the given project.
*
* @param Project $project The project whose repository updates are to be fetched.
*
* @return void
*/
public function fetch(Project $project): void
{
$this->projectConfigDirService->setDeploymentStep($project->projectDir, 'fetch');
$options = [];
if(!empty($project->deployment['repository_url']) && str_starts_with($project->deployment['repository_url'], 'http'))
{
$options['auth_basic'] = [
'username' => $project->deployment['username'] ?? '',
'password' => $project->deployment['password'] ?? '',
];
}
if($this->git->isCloned(self::GIT_DIR))
{
$this->projectConfigDirService->setDeploymentStep($project->projectDir, 'fetch.git.clone');
$this->git->fetchRepo(
dir: self::GIT_DIR,
options: $options,
);
}
else
{
$this->projectConfigDirService->setDeploymentStep($project->projectDir, 'fetch.git.fetch');
$this->git->cloneRepo(
repoUrl: $project->deployment['repository_url'],
targetDir: self::GIT_DIR,
options: [...$options, '--mirror'],
);
}
}
/**
* Deploys the specified project by preparing a clean working directory,
* cloning the git repository, and checking out the appropriate version.
*
* If the version is set to "default", it translates to the current state of
* the default branch in the repository. If set to "latest" or if no version
* is provided, the latest release of the repository is checked out.
*
* @param Project $project The project to be deployed.
* @param string $version The version to be deployed, which could be explicit, "default", or "latest".
*
* @return void
*/
public function deploy(Project $project, string $version): void
{
# remove work tree
$this->clearDeploymentFiles($project);
# create work tree
$this->git->cloneRepo(self::GIT_DIR, $this->filesDir);
// default translates to current state of default branch
if($version === 'default')
{
$version = $this->git->getRepoDefaultBranch(self::GIT_DIR);
}
if(!$version || $version === 'latest')
{
# check out latest release
$this->git->checkoutLatestRelease($this->filesDir);
}
else
{
$this->git->checkoutRepo($this->filesDir, $version);
}
parent::deploy($project, $version);
}
}

View File

@@ -0,0 +1,225 @@
<?php
/*
* GitService.php 2026-03-27 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Service\Git;
use App\Traits\LoggerTrait;
use League\Uri\Uri;
use Symfony\Component\Process\Process;
/**
* Provides functionalities for managing Git repositories, including cloning,
* fetching, checking out, and retrieving information about branches and releases.
*/
class GitService
{
use LoggerTrait;
protected string $workingDir;
public function __construct(
){
$this->workingDir = sys_get_temp_dir();
}
/**
* Sets the working directory to the specified path.
*
* @param string $dir The path to set as the working directory.
* @return self The current instance for method chaining.
*/
public function setWorkingDir(string $dir): self
{
$this->workingDir = $dir;
return $this;
}
/**
* Checks if the specified directory within the working directory is a cloned Git repository.
*
* @param string $dir The relative path to the directory to check. Defaults to '.git'.
* @return bool True if the directory exists and is a cloned Git repository, false otherwise.
*/
public function isCloned(string $dir = '.git'): bool
{
return is_dir($this->workingDir . DIRECTORY_SEPARATOR . $dir);
}
/**
* Clones a Git repository into the specified target directory with optional configurations.
*
* @param string $repoUrl The URL of the Git repository to be cloned.
* @param string $targetDir The target directory where the repository will be cloned. Defaults to an empty string, leading to the use of the repository's default folder name.
* @param array $options Optional configurations for the cloning process. Supports 'auth_basic' for basic authentication with keys:
* - 'username': The username for authentication.
* - 'password': The password for authentication.
* Other options will be passed directly to the `git clone` command.
* @return void
*/
public function cloneRepo(string $repoUrl, string $targetDir = '', array $options = []): void
{
if(!empty($options['auth_basic']))
{
$repoUrl = Uri::new($repoUrl)
->withUsername($options['auth_basic']['username'] ?? '')
->withPassword($options['auth_basic']['password'] ?? '')
->toString()
;
unset ($options['auth_basic']);
}
new Process(
command: ['git', 'clone', ...$options, $repoUrl, $targetDir],
cwd: $this->workingDir,
)->mustRun();
}
/**
* Checks out the specified branch in the Git repository located in the given directory.
*
* @param string $dir The relative path to the directory containing the Git repository.
* @param string $branch The name of the branch to check out. Defaults to 'master'.
* @return void
*/
public function checkoutRepo(string $dir, string $branch = 'master'): void
{
new Process(
command: ['git', 'checkout', '-f', $branch],
cwd: $this->workingDir . DIRECTORY_SEPARATOR . $dir,
)->mustRun();
}
/**
* Fetches all branches and tags from the Git repository within the specified directory.
*
* @param string $dir The relative path to the directory containing the Git repository.
* @param array $options Additional options for configuring the fetch process (currently unused).
* @return void
*/
public function fetchRepo(string $dir, array $options = []): void
{
Process::fromShellCommandline(
command: 'git --all --tags',
cwd: $this->workingDir . DIRECTORY_SEPARATOR . $dir,
)->mustRun();
}
/**
* Updates Git references in the specified repository directory.
*
* @param string $dir The relative path to the directory containing the Git repository.
* @param array $options Additional options for the operation (not currently utilized).
* @return void
*/
public function updateRef(string $dir, array $options = []): void
{
Process::fromShellCommandline(
command: 'git update-ref',
cwd: $this->workingDir . DIRECTORY_SEPARATOR . $dir
)->mustRun();
}
/**
* Executes a Git pull command to update all branches and fetch all tags in the specified repository directory.
*
* @param string $targetDir The relative path to the directory containing the Git repository.
* @param array $options An array of additional options (currently unused).
* @return void
*/
public function pullRepo(string $targetDir, array $options = []): void
{
Process::fromShellCommandline(
command: 'git pull --all --tags --force',
cwd: $this->workingDir . DIRECTORY_SEPARATOR . $targetDir
)->mustRun();
}
/**
* Retrieves the default branch name of the Git repository within the specified directory.
*
* @param string $dir The relative path to the directory containing the Git repository.
* @return string The default branch name, or 'master' if none is found.
*/
public function getRepoDefaultBranch(string $dir): string
{
$process = Process::fromShellCommandline(
command: 'git symbolic-ref --short HEAD',
cwd: $this->workingDir . DIRECTORY_SEPARATOR . $dir
)->mustRun();
return trim($process->getOutput()) ?: 'master';
}
/**
* Retrieves the current release tag from the Git repository within the specified directory.
*
* @param string $dir The relative path to the directory containing the Git repository.
* @return string The current release tag.
*/
public function getCurrentRelease(string $dir): string
{
$process = Process::fromShellCommandline(
command: 'git describe --tags',
cwd: $this->workingDir . DIRECTORY_SEPARATOR . $dir
)->mustRun();
return trim($process->getOutput());
}
/**
* Retrieves the latest release tag from the Git repository within the specified directory.
*
* @param string $dir The relative path to the directory containing the Git repository.
* @return string The latest release tag.
*/
public function getLatestRelease(string $dir): string
{
$process = Process::fromShellCommandline(
command: 'git tag --sort=committerdate --list "v[0-9]*" "[0-9]*.[0-9]*.[0-9]*" | tail -1',
cwd: $this->workingDir . DIRECTORY_SEPARATOR . $dir
)->mustRun();
return trim($process->getOutput());
}
/**
* Checks out the latest release version in the specified directory.
*
* Compares the current release version with the latest available version.
* If the current version is already up to date, the update process is skipped.
* Otherwise, the latest version is downloaded and checked out.
*
* @param string $dir The directory where the release is located.
*/
public function checkoutLatestRelease(string $dir): void
{
$currentRelease = $this->getCurrentRelease($dir);
$latestRelease = $this->getLatestRelease($dir);
// Update abbrechen, wenn Version bereits aktuell
if($currentRelease === $latestRelease)
{
return;
}
// neueste Version herunterladen und auschecken
$this->checkoutRepo($dir, $latestRelease);
}
}

View File

@@ -0,0 +1,46 @@
<?php
/*
* CleanProcess.php 2026-03-27 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Service\Process;
use Symfony\Component\Process\Process;
class CleanProcess extends Process
{
public function start(?callable $callback = null, array $env = []): void
{
// von docker compose gesetzte Umgebungsvariablen zurücksetzen
$this->parseEnvVars('DOCKER_DOTENV_VARS', $env);
// Symfony dotenv-Variablen für den Prozess zurücksetzen
$this->parseEnvVars('SYMFONY_DOTENV_VARS', $env);
parent::start($callback, $env);
}
private function parseEnvVars(string $keySelector, array &$env): void
{
$preservedKeys = [];
if(empty(getenv($keySelector)))
{
return;
}
// prepare DDEV-Environments
if(getenv('IS_DDEV_PROJECT'))
{
$preservedKeys += ['SSH_AUTH_SOCK'];
}
$vars = array_fill_keys(explode(',', getenv($keySelector)), false);
$env = [...array_diff_key($vars, array_flip($preservedKeys)), ...$env];
}
}

View File

@@ -0,0 +1,125 @@
<?php
/*
* ProjectConfigDirService.php 2026-03-15 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Service;
use App\Traits\LoggerTrait;
use Symfony\Component\Filesystem\Exception\IOExceptionInterface;
use Symfony\Component\Filesystem\Filesystem;
use Symfony\Component\Filesystem\Path;
use Symfony\Component\Finder\Exception\DirectoryNotFoundException;
use Symfony\Component\Finder\Finder;
use Symfony\Component\Finder\SplFileInfo;
class ProjectConfigDirService
{
use LoggerTrait;
const string FILE_NAME_DEPLOYED_VERSION = '.deployed-version';
const string FILE_NAME_DEPLOYMENT_STEP = '.deployment-step';
public function __construct(
protected string $dataDir,
){}
/**
* @return SplFileInfo[]
*/
public function getProjectDirContents(): array
{
return $this->loadProjectDirContents();
}
public function getDeployedVersion(string $projectDir): string
{
$filesystem = new Filesystem();
if(!$filesystem->exists($projectDir . DIRECTORY_SEPARATOR . self::FILE_NAME_DEPLOYED_VERSION))
{
return '';
}
$version = $filesystem->readFile($projectDir . DIRECTORY_SEPARATOR . self::FILE_NAME_DEPLOYED_VERSION);
return trim($version);
}
public function setDeployedVersion(string $projectDir, string $version): void
{
$filesystem = new Filesystem();
$filesystem->dumpFile($projectDir . DIRECTORY_SEPARATOR . self::FILE_NAME_DEPLOYED_VERSION, $version);
}
public function getDeploymentStep(string $projectDir): string
{
$filesystem = new Filesystem();
if(!$filesystem->exists($projectDir . DIRECTORY_SEPARATOR . self::FILE_NAME_DEPLOYMENT_STEP))
{
return 'not-started';
}
$stage = $filesystem->readFile($projectDir . DIRECTORY_SEPARATOR . self::FILE_NAME_DEPLOYMENT_STEP);
return trim($stage);
}
public function setDeploymentStep(string $projectDir, string $step): void
{
$filesystem = new Filesystem();
$filesystem->dumpFile($projectDir . DIRECTORY_SEPARATOR . self::FILE_NAME_DEPLOYMENT_STEP, $step);
}
/**
* @return SplFileInfo[]
*/
protected function loadProjectDirContents(): array
{
$finder = new Finder();
$content = [];
// find all files in the current directory
try
{
$finder->files()->in($this->dataDir)->depth(1)->name('project.yaml');
foreach($finder as $file)
{
$content[] = $file;
}
return $content;
}
catch(DirectoryNotFoundException $e)
{
$this->logger->error('Project directory not found: ' . $this->dataDir . '. creating ...');
$filesystem = new Filesystem();
try
{
$filesystem->mkdir(Path::normalize($this->dataDir));
$this->loadProjectDirContents();
}
catch(IOExceptionInterface $exception)
{
echo "An error occurred while creating your directory at ".$exception->getPath();
}
}
catch(\Exception $e)
{
throw new \RuntimeException('Failed to load project directory contents', 500, $e);
}
return [];
}
}

View File

@@ -0,0 +1,23 @@
<?php
/*
* LoggerTrait.php 2026-03-12 thomas
*
* Copyright (c) 2026 Thomas Schneider <thomas@inter-mundos.de>
* Alle Rechte vorbehalten.
*/
namespace App\Traits;
use Psr\Log\LoggerInterface;
use Symfony\Contracts\Service\Attribute\Required;
trait LoggerTrait
{
protected LoggerInterface $logger;
#[Required]
public function setLogger(LoggerInterface $logger): void
{
$this->logger = $logger;
}
}