Issues (3)

src/Deployer/DefaultDeployer.php (2 issues)

1
<?php
2
3
namespace Bencagri\Artisan\Deployer\Deployer;
4
5
use Bencagri\Artisan\Deployer\Configuration\DefaultConfiguration;
6
use Bencagri\Artisan\Deployer\Configuration\Option;
7
use Bencagri\Artisan\Deployer\Exception\InvalidConfigurationException;
8
use Bencagri\Artisan\Deployer\Requirement\AllowsLoginViaSsh;
9
use Bencagri\Artisan\Deployer\Requirement\CommandExists;
10
use Bencagri\Artisan\Deployer\Server\Property;
11
use Bencagri\Artisan\Deployer\Server\Server;
12
use Bencagri\Artisan\Deployer\Task\TaskCompleted;
13
14
abstract class DefaultDeployer extends AbstractDeployer
15
{
16
    private $remoteProjectDirHasBeenCreated = false;
17
    private $remoteSymLinkHasBeenCreated = false;
18
19
    public function getConfigBuilder() : DefaultConfiguration
20
    {
21
        return new DefaultConfiguration($this->getContext()->getLocalProjectRootDir());
22
    }
23
24
    public function getRequirements() : array
25
    {
26
        $requirements = [];
27
        $localhost = $this->getContext()->getLocalHost();
28
        $allServers = $this->getServers()->findAll();
29
        $appServers = $this->getServers()->findByRoles([Server::ROLE_APP]);
30
31
        $requirements[] = new CommandExists([$localhost], 'git');
32
        $requirements[] = new CommandExists([$localhost], 'ssh');
33
34
        $requirements[] = new AllowsLoginViaSsh($allServers);
35
        $requirements[] = new CommandExists($appServers, $this->getConfig(Option::remoteComposerBinaryPath));
36
        if ('acl' === $this->getConfig(Option::permissionMethod)) {
37
            $requirements[] = new CommandExists($appServers, $this->getConfig('setfacl'));
38
        }
39
40
        return $requirements;
41
    }
42
43
    final public function deploy() : void
44
    {
45
        $this->initializeServerOptions();
46
        $this->createRemoteDirectoryLayout();
47
        $this->remoteProjectDirHasBeenCreated = true;
48
49
        $this->log('Executing <hook>beforeUpdating</> hook');
50
        $this->beforeUpdating();
51
        $this->log('<h1>Updating app code</>');
52
        $this->doUpdateCode();
53
54
        $this->log('Executing <hook>beforePreparing</> hook');
55
        $this->beforePreparing();
56
        $this->log('<h1>Preparing app</>');
57
        $this->doCreateCacheDir();
58
        $this->doCreateLogDir();
59
        $this->doCreateSharedDirs();
60
        $this->doCreateSharedFiles();
61
        $this->doSetPermissions();
62
        $this->doInstallDependencies();
63
64
        $this->log('Executing <hook>beforeOptimizing</> hook');
65
        $this->beforeOptimizing();
66
        $this->log('<h1>Optimizing app</>');
67
        $this->doWarmupCache();
68
        $this->doClearControllers();
69
        $this->doOptimizeComposer();
70
71
        $this->log('Executing <hook>beforePublishing</> hook');
72
        $this->beforePublishing();
73
        $this->log('<h1>Publishing app</>');
74
        $this->doCreateSymlink();
75
        $this->remoteSymLinkHasBeenCreated = true;
76
        $this->doResetOpCache();
77
        $this->doKeepReleases();
78
    }
79
80
    final public function cancelDeploy() : void
81
    {
82
        if (!$this->remoteSymLinkHasBeenCreated && !$this->remoteProjectDirHasBeenCreated) {
83
            $this->log('<h2>No changes need to be reverted on remote servers (neither the remote project dir nor the symlink were created)</>');
84
        }
85
86
        if ($this->remoteSymLinkHasBeenCreated) {
87
            $this->doSymlinkToPreviousRelease();
88
        }
89
90
        if ($this->remoteProjectDirHasBeenCreated) {
91
            $this->doDeleteLastReleaseDirectory();
92
        }
93
    }
94
95
    final public function rollback() : void
96
    {
97
        $this->initializeServerOptions();
98
99
        $this->log('Executing <hook>beforeRollingBack</> hook');
100
        $this->beforeRollingBack();
101
102
        $this->doCheckPreviousReleases();
103
        $this->doSymlinkToPreviousRelease();
104
        $this->doDeleteLastReleaseDirectory();
105
    }
106
107
    public function beforeUpdating()
108
    {
109
        $this->log('<h3>Nothing to execute</>');
110
    }
111
112
    public function beforePreparing()
113
    {
114
        $this->log('<h3>Nothing to execute</>');
115
    }
116
117
    public function beforeOptimizing()
118
    {
119
        $this->log('<h3>Nothing to execute</>');
120
    }
121
122
    public function beforePublishing()
123
    {
124
        $this->log('<h3>Nothing to execute</>');
125
    }
126
127
    public function beforeRollingBack()
128
    {
129
        $this->log('<h3>Nothing to execute</>');
130
    }
131
132
    private function doCheckPreviousReleases() : void
133
    {
134
        $this->log('<h2>Getting the previous releases dirs</>');
135
        $results = $this->runRemote('ls -r1 {{ deploy_dir }}/releases');
136
137
        if ($this->getContext()->isDryRun()) {
138
            return;
139
        }
140
141
        foreach ($results as $result) {
142
            $numReleases = count(array_filter(explode("\n", $result->getOutput())));
143
144
            if ($numReleases < 2) {
145
                throw new \RuntimeException(sprintf('The application cannot be rolled back because the "%s" server has only 1 release and it\'s not possible to roll back to a previous version.', $result->getServer()));
146
            }
147
        }
148
    }
149
150
    private function doSymlinkToPreviousRelease() : void
151
    {
152
        $this->log('<h2>Reverting the current symlink to the previous version</>');
153
        $this->runRemote('export _previous_release_dirname=$(ls -r1 {{ deploy_dir }}/releases | head -n 2 | tail -n 1) && rm -f {{ deploy_dir }}/current && ln -s {{ deploy_dir }}/releases/$_previous_release_dirname {{ deploy_dir }}/current');
154
    }
155
156
    private function doDeleteLastReleaseDirectory() : void
157
    {
158
        // this is needed to avoid rolling back in the future to this version
159
        $this->log('<h2>Deleting the last release directory</>');
160
        $this->runRemote('export _last_release_dirname=$(ls -r1 {{ deploy_dir }}/releases | head -n 1) && rm -fr {{ deploy_dir }}/releases/$_last_release_dirname');
161
    }
162
163
    private function initializeServerOptions() : void
164
    {
165
        $this->log('<h2>Initializing server options</>');
166
167
        /** @var Server[] $allServers */
168
        $allServers = array_merge([$this->getContext()->getLocalHost()], $this->getServers()->findAll());
169
        foreach ($allServers as $server) {
170
            if (true === $this->getConfig(Option::useSshAgentForwarding)) {
171
                $this->log(sprintf('<h3>Enabling SSH agent forwarding for <server>%s</> server</>', $server));
172
            }
173
            $server->set(Property::use_ssh_agent_forwarding, $this->getConfig(Option::useSshAgentForwarding));
174
        }
175
176
        $appServers = $this->getServers()->findByRoles([Server::ROLE_APP]);
177
        foreach ($appServers as $server) {
178
            $this->log(sprintf('<h3>Setting the %s property for <server>%s</> server</>', Property::deploy_dir, $server));
179
            $server->set(Property::deploy_dir, $this->getConfig(Option::deployDir));
180
        }
181
    }
182
183
    private function initializeDirectoryLayout(Server $server) : void
184
    {
185
        $this->log('<h2>Initializing server directory layout</>');
186
187
        $remoteProjectDir = $server->get(Property::project_dir);
188
        $server->set(Property::config_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::configDir)));
189
        $server->set(Property::cache_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::cacheDir)));
190
        $server->set(Property::log_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::logDir)));
191
        $server->set(Property::src_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::srcDir)));
192
        $server->set(Property::templates_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::templatesDir)));
193
        $server->set(Property::web_dir, sprintf('%s/%s', $remoteProjectDir, $this->getConfig(Option::webDir)));
194
195
196
        $server->set(Property::console_bin, sprintf('%s artisan', $this->getConfig(Option::remotePhpBinaryPath)));
197
    }
198
199
200
    private function findConsoleBinaryPath(Server $server) : string
0 ignored issues
show
The method findConsoleBinaryPath() is not used, and could be removed.

This check looks for private methods that have been defined, but are not used inside the class.

Loading history...
201
    {
202
        $consoleBinary = '{{ project_dir }}/artisan';
203
204
        $localConsoleBinary = $this->getContext()->getLocalHost()->resolveProperties($consoleBinary);
205
        if (is_executable($localConsoleBinary)) {
206
            return $server->resolveProperties($consoleBinary);
207
        }
208
209
        if (null === $server->get(Property::console_bin)) {
210
            throw new InvalidConfigurationException(sprintf('The artisan binary of your laravel application is not available in any of the following directories: %s. Configure the "baseDir" option and set it to the directory that contains the "artisan" binary.', $consoleBinary));
211
        }
0 ignored issues
show
Bug Best Practice introduced by
The function implicitly returns null when the if condition on line 209 is false. This is incompatible with the type-hinted return string. Consider adding a return statement or allowing null as return value.

For hinted functions/methods where all return statements with the correct type are only reachable via conditions, ?null? gets implicitly returned which may be incompatible with the hinted type. Let?s take a look at an example:

interface ReturnsInt {
    public function returnsIntHinted(): int;
}

class MyClass implements ReturnsInt {
    public function returnsIntHinted(): int
    {
        if (foo()) {
            return 123;
        }
        // here: null is implicitly returned
    }
}
Loading history...
212
    }
213
214
    private function createRemoteDirectoryLayout() : void
215
    {
216
        $this->log('<h2>Creating the remote directory layout</>');
217
        $this->runRemote('mkdir -p {{ deploy_dir }} && mkdir -p {{ deploy_dir }}/releases && mkdir -p {{ deploy_dir }}/shared');
218
219
        /** @var TaskCompleted[] $results */
220
        $results = $this->runRemote('export _release_path="{{ deploy_dir }}/releases/$(date +%Y%m%d%H%M%S)" && mkdir -p $_release_path && echo $_release_path');
221
        foreach ($results as $result) {
222
            $remoteProjectDir = $this->getContext()->isDryRun() ? '(the remote project_dir)' : $result->getTrimmedOutput();
223
            $result->getServer()->set(Property::project_dir, $remoteProjectDir);
224
            $this->initializeDirectoryLayout($result->getServer());
225
        }
226
    }
227
228
    private function doGetcodeRevision() : string
229
    {
230
        $this->log('<h2>Getting the revision ID of the code repository</>');
231
        $result = $this->runLocal(sprintf('git ls-remote %s %s', $this->getConfig(Option::repositoryUrl), $this->getConfig(Option::repositoryBranch)));
232
        $revision = explode("\t", $result->getTrimmedOutput())[0];
233
        if ($this->getContext()->isDryRun()) {
234
            $revision = '(the code revision)';
235
        }
236
        $this->log(sprintf('<h3>Code revision hash = %s</>', $revision));
237
238
        return $revision;
239
    }
240
241
    private function doUpdateCode() : void
242
    {
243
        $repositoryRevision = $this->doGetcodeRevision();
244
245
        $this->log('<h2>Updating code base with remote_cache strategy</>');
246
        $this->runRemote(sprintf('if [ -d {{ deploy_dir }}/repo ]; then cd {{ deploy_dir }}/repo && git fetch -q origin && git fetch --tags -q origin && git reset -q --hard %s && git clean -q -d -x -f; else git clone -q -b %s %s {{ deploy_dir }}/repo && cd {{ deploy_dir }}/repo && git checkout -q -b deploy %s; fi', $repositoryRevision, $this->getConfig(Option::repositoryBranch), $this->getConfig(Option::repositoryUrl), $repositoryRevision));
247
248
        $this->log('<h3>Copying the updated code to the new release directory</>');
249
        $this->runRemote(sprintf('cp -RPp {{ deploy_dir }}/repo/* {{ project_dir }}'));
250
    }
251
252
    private function doCreateCacheDir() : void
253
    {
254
        $this->log('<h2>Creating cache directory</>');
255
        $this->runRemote('if [ -d {{ cache_dir }} ]; then rm -rf {{ cache_dir }}; fi; mkdir -p {{ cache_dir }}');
256
    }
257
258
    private function doCreateLogDir() : void
259
    {
260
        $this->log('<h2>Creating log directory</>');
261
        $this->runRemote('if [ -d {{ log_dir }} ] ; then rm -rf {{ log_dir }}; fi; mkdir -p {{ log_dir }}');
262
    }
263
264
    private function doCreateSharedDirs() : void
265
    {
266
        $this->log('<h2>Creating symlinks for shared directories</>');
267
        foreach ($this->getConfig(Option::sharedDirs) as $sharedDir) {
268
            $this->runRemote(sprintf('mkdir -p {{ deploy_dir }}/shared/%s', $sharedDir));
269
            $this->runRemote(sprintf('if [ -d {{ project_dir }}/%s ] ; then rm -rf {{ project_dir }}/%s; fi', $sharedDir, $sharedDir));
270
            $this->runRemote(sprintf('ln -nfs {{ deploy_dir }}/shared/%s {{ project_dir }}/%s', $sharedDir, $sharedDir));
271
        }
272
    }
273
274
    private function doCreateSharedFiles() : void
275
    {
276
        $this->log('<h2>Creating symlinks for shared files</>');
277
        if (!is_array($this->getConfig(Option::sharedFiles))) return;
278
279
        foreach ($this->getConfig(Option::sharedFiles) as $sharedFile) {
280
            $sharedFileParentDir = dirname($sharedFile);
281
            $this->runRemote(sprintf('mkdir -p {{ deploy_dir }}/shared/%s', $sharedFileParentDir));
282
            $this->runRemote(sprintf('touch {{ deploy_dir }}/shared/%s', $sharedFile));
283
            $this->runRemote(sprintf('ln -nfs {{ deploy_dir }}/shared/%s {{ project_dir }}/%s', $sharedFile, $sharedFile));
284
        }
285
    }
286
287
    // this method was inspired by https://github.com/deployphp/deployer/blob/master/recipe/deploy/writable.php
288
    // (c) Anton Medvedev <[email protected]>
289
    private function doSetPermissions() : void
290
    {
291
        $permissionMethod = $this->getConfig(Option::permissionMethod);
292
        $writableDirs = implode(' ', $this->getConfig(Option::writableDirs));
293
        $this->log(sprintf('<h2>Setting permissions for writable dirs using the "%s" method</>', $permissionMethod));
294
295
        if ('chmod' === $permissionMethod) {
296
            $this->runRemote(sprintf('chmod -R %s %s', $this->getConfig(Option::permissionMode), $writableDirs));
297
298
            return;
299
        }
300
301
        if ('chown' === $permissionMethod) {
302
            $this->runRemote(sprintf('sudo chown -RL %s %s', $this->getConfig(Option::permissionUser), $writableDirs));
303
304
            return;
305
        }
306
307
        if ('chgrp' === $permissionMethod) {
308
            $this->runRemote(sprintf('sudo chgrp -RH %s %s', $this->getConfig(Option::permissionGroup), $writableDirs));
309
310
            return;
311
        }
312
313
        if ('acl' === $permissionMethod) {
314
            $this->runRemote(sprintf('sudo setfacl -RL -m u:"%s":rwX -m u:`whoami`:rwX %s', $this->getConfig(Option::permissionUser), $writableDirs));
315
            $this->runRemote(sprintf('sudo setfacl -dRL -m u:"%s":rwX -m u:`whoami`:rwX %s', $this->getConfig(Option::permissionUser), $writableDirs));
316
317
            return;
318
        }
319
320
        throw new InvalidConfigurationException(sprintf('The "%s" permission method is not valid. Select one of the supported methods.', $permissionMethod));
321
    }
322
323
    private function doInstallDependencies() : void
324
    {
325
        if (true === $this->getConfig(Option::updateRemoteComposerBinary)) {
326
            $this->log('<h2>Self Updating the Composer binary</>');
327
            $this->runRemote(sprintf('%s self-update', $this->getConfig(Option::remoteComposerBinaryPath)));
328
        }
329
330
        $this->log('<h2>Installing Composer dependencies</>');
331
        $this->runRemote(sprintf('%s install %s', $this->getConfig(Option::remoteComposerBinaryPath), $this->getConfig(Option::composerInstallFlags)));
332
    }
333
334
    private function doWarmupCache() : void
335
    {
336
        if (true !== $this->getConfig(Option::warmupCache)) {
337
            return;
338
        }
339
340
        $this->log('<h2>Clearing Cache</>');
341
        $this->runRemote(sprintf('{{ console_bin }} cache:clear --no-debug --env=%s', $this->getConfig(Option::appEnvironment)));
342
        $this->runRemote('chmod -R g+w {{ cache_dir }}');
343
    }
344
345
    private function doClearControllers() : void
346
    {
347
        $this->log('<h2>Clearing controllers</>');
348
        foreach ($this->getServers()->findByRoles([Server::ROLE_APP]) as $server) {
349
            $absolutePaths = array_map(function ($relativePath) use ($server) {
350
                return $server->resolveProperties(sprintf('{{ project_dir }}/%s', $relativePath));
351
            }, $this->getConfig(Option::controllersToRemove));
352
353
            $this->safeDelete($server, $absolutePaths);
354
        }
355
    }
356
357
    private function doOptimizeComposer() : void
358
    {
359
        $this->log('<h2>Optimizing Composer autoloader</>');
360
        $this->runRemote(sprintf('%s dump-autoload %s', $this->getConfig(Option::remoteComposerBinaryPath), $this->getConfig(Option::composerOptimizeFlags)));
361
    }
362
363
    private function doCreateSymlink() : void
364
    {
365
        $this->log('<h2>Updating the symlink</>');
366
        $this->runRemote('rm -f {{ deploy_dir }}/current && ln -s {{ project_dir }} {{ deploy_dir }}/current');
367
    }
368
369
    private function doResetOpCache() : void
370
    {
371
        if (null === $homepageUrl = $this->getConfig(Option::resetOpCacheFor)) {
372
            return;
373
        }
374
375
        $this->log('<h2>Resetting the OPcache contents</>');
376
        $phpScriptPath = sprintf('__easy_deploy_opcache_reset_%s.php', bin2hex(random_bytes(8)));
377
        $this->runRemote(sprintf('echo "<?php opcache_reset();" > {{ web_dir }}/%s && wget %s/%s && rm -f {{ web_dir }}/%s', $phpScriptPath, $homepageUrl, $phpScriptPath, $phpScriptPath));
378
    }
379
380
    private function doKeepReleases() : void
381
    {
382
        if (-1 === $this->getConfig(Option::keepReleases)) {
383
            $this->log('<h3>No releases to delete</>');
384
385
            return;
386
        }
387
388
        $results = $this->runRemote('ls -1 {{ deploy_dir }}/releases');
389
        foreach ($results as $result) {
390
            $this->deleteOldReleases($result->getServer(), explode("\n", $result->getTrimmedOutput()));
391
        }
392
    }
393
394
    private function deleteOldReleases(Server $server, array $releaseDirs) : void
395
    {
396
        foreach ($releaseDirs as $releaseDir) {
397
            if (!preg_match('/\d{14}/', $releaseDir)) {
398
                $this->log(sprintf('[<server>%s</>] Skipping cleanup of old releases; unexpected "%s" directory found (all directory names should be timestamps)', $server, $releaseDir));
399
400
                return;
401
            }
402
        }
403
404
        if (count($releaseDirs) <= $this->getConfig(Option::keepReleases)) {
405
            $this->log(sprintf('[<server>%s</>] No releases to delete (there are %d releases and the config keeps %d releases).', $server, count($releaseDirs), $this->getConfig(Option::keepReleases)));
406
407
            return;
408
        }
409
410
        $relativeDirsToRemove = array_slice($releaseDirs, 0, -$this->getConfig(Option::keepReleases));
411
        $absoluteDirsToRemove = array_map(function ($v) {
412
            return sprintf('%s/releases/%s', $this->getConfig(Option::deployDir), $v);
413
        }, $relativeDirsToRemove);
414
415
        // the command must be run only on one server because the timestamps are
416
        // different for all servers, even when they belong to the same deploy and
417
        // because new servers may have been added to the deploy and old releases don't exist on them
418
        $this->log(sprintf('Deleting these old release directories: %s', implode(', ', $absoluteDirsToRemove)));
419
        $this->safeDelete($server, $absoluteDirsToRemove);
420
    }
421
}
422