Skip to content

Commit 7a5f62f

Browse files
committed
feat: add laravel:vapor:migrate command
1 parent eb136e2 commit 7a5f62f

6 files changed

Lines changed: 1113 additions & 1 deletion

File tree

Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
/*
6+
* This file is part of Ymir command-line tool.
7+
*
8+
* (c) Carl Alexander <support@ymirapp.com>
9+
*
10+
* For the full copyright and license information, please view the LICENSE
11+
* file that was distributed with this source code.
12+
*/
13+
14+
namespace Ymir\Cli\Command\Laravel;
15+
16+
use Illuminate\Support\Collection;
17+
use Symfony\Component\Filesystem\Filesystem;
18+
use Symfony\Component\Yaml\Yaml;
19+
use Ymir\Cli\ApiClient;
20+
use Ymir\Cli\Command\AbstractCommand;
21+
use Ymir\Cli\Command\LocalProjectCommandInterface;
22+
use Ymir\Cli\Dockerfile;
23+
use Ymir\Cli\Exception\InvalidInputException;
24+
use Ymir\Cli\Exception\Project\UnsupportedProjectException;
25+
use Ymir\Cli\ExecutionContextFactory;
26+
use Ymir\Cli\Project\Configuration\VaporConfigurationChange;
27+
use Ymir\Cli\Project\EnvironmentConfiguration;
28+
use Ymir\Cli\Project\Type\LaravelProjectType;
29+
use Ymir\Cli\Support\Arr;
30+
31+
class MigrateVaporCommand extends AbstractCommand implements LocalProjectCommandInterface
32+
{
33+
/**
34+
* The name of the command.
35+
*
36+
* @var string
37+
*/
38+
public const NAME = 'laravel:vapor:migrate';
39+
40+
/**
41+
* The project Dockerfile service.
42+
*
43+
* @var Dockerfile
44+
*/
45+
private $dockerfile;
46+
47+
/**
48+
* The file system.
49+
*
50+
* @var Filesystem
51+
*/
52+
private $filesystem;
53+
54+
/**
55+
* Constructor.
56+
*/
57+
public function __construct(ApiClient $apiClient, ExecutionContextFactory $contextFactory, Dockerfile $dockerfile, Filesystem $filesystem)
58+
{
59+
parent::__construct($apiClient, $contextFactory);
60+
61+
$this->dockerfile = $dockerfile;
62+
$this->filesystem = $filesystem;
63+
}
64+
65+
/**
66+
* {@inheritdoc}
67+
*/
68+
protected function configure()
69+
{
70+
$this
71+
->setName(self::NAME)
72+
->setDescription('Migrate vapor.yml environment configuration into ymir.yml');
73+
}
74+
75+
/**
76+
* {@inheritdoc}
77+
*/
78+
protected function perform()
79+
{
80+
if (!$this->getProjectConfiguration()->getProjectType() instanceof LaravelProjectType) {
81+
throw new UnsupportedProjectException('You can only use this command with Laravel projects');
82+
}
83+
84+
$vaporConfiguration = $this->getVaporConfiguration();
85+
$vaporEnvironments = Arr::get($vaporConfiguration, 'environments');
86+
87+
if (!is_array($vaporEnvironments)) {
88+
throw new InvalidInputException('No valid "environments" key found in vapor.yml file');
89+
}
90+
91+
$matchedEnvironments = $this->getProjectConfiguration()->getEnvironments()->keys()->intersect(collect($vaporEnvironments)->keys())->values();
92+
93+
if ($matchedEnvironments->isEmpty()) {
94+
$this->output->warning('No matching environments found between ymir.yml and vapor.yml files');
95+
96+
return;
97+
}
98+
99+
$this->getProjectConfiguration()->applyChangesToEnvironments(new VaporConfigurationChange($vaporConfiguration));
100+
101+
$imageDeploymentEnvironmentConfigurations = $this->getImageDeploymentEnvironmentConfigurations($matchedEnvironments);
102+
103+
if (!$imageDeploymentEnvironmentConfigurations->isEmpty()) {
104+
$this->migrateDockerfiles($imageDeploymentEnvironmentConfigurations);
105+
}
106+
107+
$this->output->info('Vapor configuration migrated into <comment>ymir.yml</comment> file for the following environment(s):');
108+
$this->output->list($matchedEnvironments);
109+
}
110+
111+
/**
112+
* Back up all relevant Dockerfiles before migration.
113+
*/
114+
private function backupDockerfiles(Collection $environmentConfigurations, bool $backupGlobalDockerfile): void
115+
{
116+
$dockerfilePaths = $environmentConfigurations
117+
->map(function (EnvironmentConfiguration $environmentConfiguration): string {
118+
return $this->generateDockerfilePath($environmentConfiguration->getName());
119+
})
120+
->unique();
121+
122+
if ($backupGlobalDockerfile) {
123+
$dockerfilePaths->push($this->generateDockerfilePath());
124+
}
125+
126+
$dockerfilePaths->filter(function (string $dockerfilePath): bool {
127+
return $this->filesystem->exists($dockerfilePath);
128+
})->each(function (string $dockerfilePath): void {
129+
$this->filesystem->rename($dockerfilePath, $dockerfilePath.'.bak', true);
130+
});
131+
}
132+
133+
/**
134+
* Create a Dockerfile.
135+
*/
136+
private function createDockerfile(EnvironmentConfiguration $environmentConfiguration, string $phpVersion, bool $globalDockerfile): void
137+
{
138+
$architecture = $environmentConfiguration->getArchitecture() ?: 'x86_64';
139+
$environment = $globalDockerfile ? '' : $environmentConfiguration->getName();
140+
141+
$this->dockerfile->create($architecture, $phpVersion, $environment);
142+
143+
$this->output->info($this->generateDockerfileCreatedMessage($architecture, $environment, $phpVersion));
144+
145+
if ($globalDockerfile) {
146+
$this->output->comment(sprintf('Using <comment>%s</comment> environment configuration', $environmentConfiguration->getName()));
147+
}
148+
}
149+
150+
/**
151+
* Generate the success message after creating the Dockerfile.
152+
*/
153+
private function generateDockerfileCreatedMessage(string $architecture, string $environment, string $phpVersion): string
154+
{
155+
return sprintf('Created <comment>%s</comment> for PHP <comment>%s</comment> and <comment>%s</comment> architecture', Dockerfile::getFileName($environment), $phpVersion, $architecture);
156+
}
157+
158+
/**
159+
* Generate the full Dockerfile path.
160+
*/
161+
private function generateDockerfilePath(string $environment = ''): string
162+
{
163+
return sprintf('%s/%s', $this->getProjectDirectory(), Dockerfile::getFileName($environment));
164+
}
165+
166+
/**
167+
* Get the fallback PHP version from environment or project type.
168+
*/
169+
private function getFallbackPhpVersion(EnvironmentConfiguration $environmentConfiguration): string
170+
{
171+
return empty($environmentConfiguration->getPhpVersion()) ? $this->getProjectConfiguration()->getProjectType()->getDefaultPhpVersion() : $environmentConfiguration->getPhpVersion();
172+
}
173+
174+
/**
175+
* Get the image deployment environment configurations to use for Dockerfile migration.
176+
*/
177+
private function getImageDeploymentEnvironmentConfigurations(Collection $matchedEnvironments): Collection
178+
{
179+
return $matchedEnvironments
180+
->map(function (string $environment): EnvironmentConfiguration {
181+
return $this->getProjectConfiguration()->getEnvironmentConfiguration($environment);
182+
})
183+
->filter(function (EnvironmentConfiguration $environmentConfiguration): bool {
184+
return $environmentConfiguration->isImageDeploymentType();
185+
})
186+
->values();
187+
}
188+
189+
/**
190+
* Get the parsed Vapor configuration.
191+
*/
192+
private function getVaporConfiguration(): array
193+
{
194+
$vaporConfigurationFilePath = $this->getProjectDirectory().'/vapor.yml';
195+
196+
if (!$this->filesystem->exists($vaporConfigurationFilePath)) {
197+
throw new InvalidInputException(sprintf('No vapor configuration file found at "%s"', $vaporConfigurationFilePath));
198+
}
199+
200+
try {
201+
$vaporConfiguration = Yaml::parse((string) file_get_contents($vaporConfigurationFilePath));
202+
} catch (\Throwable $exception) {
203+
throw new InvalidInputException(sprintf('Error parsing Vapor configuration file: %s', $exception->getMessage()));
204+
}
205+
206+
if (!is_array($vaporConfiguration)) {
207+
throw new InvalidInputException('Error parsing Vapor configuration file');
208+
}
209+
210+
return $vaporConfiguration;
211+
}
212+
213+
/**
214+
* Migrate Dockerfiles for image deployment environments.
215+
*/
216+
private function migrateDockerfiles(Collection $environmentConfigurations): void
217+
{
218+
$phpVersions = $environmentConfigurations->mapWithKeys(function (EnvironmentConfiguration $environmentConfiguration): array {
219+
return [$environmentConfiguration->getName() => $this->resolvePhpVersion($environmentConfiguration, $this->generateDockerfilePath($environmentConfiguration->getName()))];
220+
});
221+
$createGlobalDockerfile = $this->output->confirm('Do you want to create one global <comment>Dockerfile</comment> for all image deployment environments?', false);
222+
$sourceEnvironmentConfiguration = $this->selectDockerfileSourceEnvironmentConfiguration($environmentConfigurations);
223+
$dockerfileConfigurations = $createGlobalDockerfile ? collect([$sourceEnvironmentConfiguration]) : $environmentConfigurations;
224+
225+
$this->backupDockerfiles($environmentConfigurations, $createGlobalDockerfile);
226+
227+
$dockerfileConfigurations->each(function (EnvironmentConfiguration $environmentConfiguration) use ($createGlobalDockerfile, $phpVersions): void {
228+
$this->createDockerfile($environmentConfiguration, (string) $phpVersions->get($environmentConfiguration->getName()), $createGlobalDockerfile);
229+
});
230+
}
231+
232+
/**
233+
* Resolve the PHP version from the existing Dockerfile content.
234+
*/
235+
private function resolvePhpVersion(EnvironmentConfiguration $environmentConfiguration, string $dockerfilePath): string
236+
{
237+
if (!$this->filesystem->exists($dockerfilePath)) {
238+
return $this->getFallbackPhpVersion($environmentConfiguration);
239+
}
240+
241+
$dockerfileContent = (string) file_get_contents($dockerfilePath);
242+
243+
if (1 !== preg_match('/:php-?(?:(\d+)\.(\d+)|(\d{2,3}))/i', $dockerfileContent, $matches)) {
244+
return $this->getFallbackPhpVersion($environmentConfiguration);
245+
} elseif (!empty($matches[1]) && !empty($matches[2])) {
246+
return sprintf('%s.%s', $matches[1], $matches[2]);
247+
} elseif (empty($matches[3])) {
248+
return $this->getFallbackPhpVersion($environmentConfiguration);
249+
}
250+
251+
$version = $matches[3];
252+
253+
return 2 === strlen($version) ? sprintf('%s.%s', $version[0], $version[1]) : sprintf('%s.%s', $version[0], substr($version, 1));
254+
}
255+
256+
/**
257+
* Select the source environment for Dockerfile generation.
258+
*/
259+
private function selectDockerfileSourceEnvironmentConfiguration(Collection $environmentConfigurations): EnvironmentConfiguration
260+
{
261+
$productionEnvironmentConfiguration = $environmentConfigurations->first(function (EnvironmentConfiguration $environmentConfiguration): bool {
262+
return 'production' === $environmentConfiguration->getName();
263+
});
264+
265+
return $productionEnvironmentConfiguration instanceof EnvironmentConfiguration ? $productionEnvironmentConfiguration : $environmentConfigurations->first();
266+
}
267+
}

0 commit comments

Comments
 (0)