-
-
Notifications
You must be signed in to change notification settings - Fork 1.1k
/
RsyncCommands.php
161 lines (142 loc) · 8.27 KB
/
RsyncCommands.php
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
<?php
declare(strict_types=1);
namespace Drush\Commands\core;
use Consolidation\AnnotatedCommand\CommandData;
use Consolidation\AnnotatedCommand\Hooks\HookManager;
use Consolidation\SiteAlias\HostPath;
use Consolidation\SiteAlias\SiteAliasManagerInterface;
use Consolidation\SiteProcess\Util\Escape;
use Drush\Attributes as CLI;
use Drush\Backend\BackendPathEvaluator;
use Drush\Boot\DrupalBootLevels;
use Drush\Commands\AutowireTrait;
use Drush\Commands\DrushCommands;
use Drush\Config\ConfigLocator;
use Drush\Exceptions\UserAbortException;
use Symfony\Component\Console\Event\ConsoleCommandEvent;
#[CLI\Bootstrap(DrupalBootLevels::NONE)]
final class RsyncCommands extends DrushCommands
{
use AutowireTrait;
/**
* These are arguments after the aliases and paths have been evaluated.
* @see validate().
*/
const RSYNC = 'core:rsync';
/** @var HostPath */
public $sourceEvaluatedPath;
/** @var HostPath */
public $targetEvaluatedPath;
protected BackendPathEvaluator $pathEvaluator;
public function __construct(
private readonly SiteAliasManagerInterface $siteAliasManager
) {
parent::__construct();
// TODO: once the BackendInvoke service exists, inject it here
// and use it to get the path evaluator
$this->pathEvaluator = new BackendPathEvaluator();
}
/**
* Rsync Drupal code or files to/from another server using ssh.
*/
#[CLI\Command(name: self::RSYNC, aliases: ['rsync', 'core-rsync'])]
#[CLI\Argument(name: 'source', description: 'A site alias and optional path. See rsync documentation and [Site aliases](../site-aliases.md).')]
#[CLI\Argument(name: 'target', description: 'A site alias and optional path. See rsync documentation and [Site aliases](../site-aliases.md).')]
#[CLI\Argument(name: 'extra', description: 'Additional parameters after the ssh statement.')]
#[CLI\Option(name: 'exclude-paths', description: 'List of paths to exclude, seperated by : (Unix-based systems) or ; (Windows).')]
#[CLI\Option(name: 'include-paths', description: 'List of paths to include, seperated by : (Unix-based systems) or ; (Windows).')]
#[CLI\Option(name: 'mode', description: 'The unary flags to pass to rsync; --mode=rultz implies rsync -rultz. Default is -akz.')]
#[CLI\OptionsetSsh]
#[CLI\Usage(name: 'drush rsync @dev @stage', description: 'Rsync Drupal root from Drush alias dev to the alias stage.')]
#[CLI\Usage(name: 'drush rsync ./ @stage:%files/img', description: 'Rsync all files in the current directory to the <info>img</info>directory in the file storage folder on the Drush alias stage.')]
#[CLI\Usage(name: 'drush rsync @live:%private @stage:%private', description: 'Rsync private files from live to stage.')]
#[CLI\Usage(name: 'drush rsync @dev @stage -- --exclude=*.sql --delete', description: 'Rsync Drupal root from the Drush alias dev to the alias stage, excluding all .sql files and delete all files on the destination that are no longer on the source.')]
#[CLI\Usage(name: 'drush rsync @dev @stage --ssh-options="-o StrictHostKeyChecking=no" -- --delete', description: 'Customize how rsync connects with remote host via SSH. rsync options like --delete are placed after a --.')]
#[CLI\Topics(topics: [DocsCommands::ALIASES])]
public function rsync($source, $target, array $extra, $options = ['exclude-paths' => self::REQ, 'include-paths' => self::REQ, 'mode' => 'akz']): void
{
// Prompt for confirmation. This is destructive.
if (!$this->getConfig()->simulate()) {
$replacements = ['!source' => $this->sourceEvaluatedPath->fullyQualifiedPathPreservingTrailingSlash(), '!target' => $this->targetEvaluatedPath->fullyQualifiedPath()];
if (!$this->io()->confirm(dt("Copy new and override existing files at !target. The source is !source?", $replacements))) {
throw new UserAbortException();
}
}
$rsync_options = $this->rsyncOptions($options);
$parameters = array_merge([$rsync_options], $extra);
$parameters[] = Escape::shellArg($this->sourceEvaluatedPath->fullyQualifiedPathPreservingTrailingSlash());
$parameters[] = Escape::shellArg($this->targetEvaluatedPath->fullyQualifiedPath());
$ssh_options = $this->getConfig()->get('ssh.options', '');
$exec = "rsync -e 'ssh $ssh_options'" . ' ' . implode(' ', array_filter($parameters));
$process = $this->processManager()->shell($exec);
$process->run($process->showRealtime());
if (!$process->isSuccessful()) {
throw new \Exception(dt("Could not rsync from !source to !dest", ['!source' => $this->sourceEvaluatedPath->fullyQualifiedPathPreservingTrailingSlash(), '!dest' => $this->targetEvaluatedPath->fullyQualifiedPath()]));
}
}
public function rsyncOptions($options): string
{
$verbose = $paths = '';
// Process --include-paths and --exclude-paths options the same way
foreach (['include', 'exclude'] as $include_exclude) {
// Get the option --include-paths or --exclude-paths and explode to an array of paths
// that we will translate into an --include or --exclude option to pass to rsync
$inc_ex_path = explode(PATH_SEPARATOR, (string) @$options[$include_exclude . '-paths']);
foreach ($inc_ex_path as $one_path_to_inc_ex) {
if (!empty($one_path_to_inc_ex)) {
$paths .= ' --' . $include_exclude . '="' . $one_path_to_inc_ex . '"';
}
}
}
$mode = '-' . $options['mode'];
if ($this->output()->isVerbose()) {
$mode .= 'v';
$verbose = ' --stats --progress';
}
return implode(' ', array_filter([$mode, $verbose, $paths]));
}
/**
* Evaluate the path aliases in the source and destination
* parameters. We do this in the command-event so that
* we can set up the configuration object to include options
* from the source and target aliases, if any, so that these
* values may participate in configuration injection.
*/
#[CLI\Hook(type: HookManager::COMMAND_EVENT, target: self::RSYNC)]
public function preCommandEvent(ConsoleCommandEvent $event): void
{
$input = $event->getInput();
$this->sourceEvaluatedPath = $this->injectAliasPathParameterOptions($input, 'source');
$this->targetEvaluatedPath = $this->injectAliasPathParameterOptions($input, 'target');
}
protected function injectAliasPathParameterOptions($input, $parameterName)
{
// The Drush configuration object is a ConfigOverlay; fetch the alias
// context, that already has the options et. al. from the
// site-selection alias ('drush @site rsync ...'), @self.
$aliasConfigContext = $this->getConfig()->getContext(ConfigLocator::ALIAS_CONTEXT);
$aliasName = $input->getArgument($parameterName);
$evaluatedPath = HostPath::create($this->siteAliasManager, $aliasName);
$this->pathEvaluator->evaluate($evaluatedPath);
$aliasRecord = $evaluatedPath->getSiteAlias();
// If the path is remote, then we will also inject the global
// options into the alias config context so that we pick up
// things like ssh-options.
if ($aliasRecord->isRemote()) {
assert($aliasConfigContext instanceof \Consolidation\Config\Config);
$aliasConfigContext->combine($aliasRecord->export());
}
return $evaluatedPath;
}
/**
* Validate that passed aliases are valid.
*/
#[CLI\Hook(type: HookManager::ARGUMENT_VALIDATOR, target: self::RSYNC)]
public function validate(CommandData $commandData): void
{
if ($this->sourceEvaluatedPath->isRemote() && $this->targetEvaluatedPath->isRemote()) {
$msg = dt("Cannot specify two remote aliases. Instead, use one of the following alternate options:\n\n `drush {source} rsync @self {target}`\n `drush {source} rsync @self {fulltarget}\n\nUse the second form if the site alias definitions are not available at {source}.", ['source' => $this->sourceEvaluatedPath->getSiteAlias()->name(), 'target' => $this->targetEvaluatedPath->getSiteAlias()->name(), 'fulltarget' => $this->targetEvaluatedPath->fullyQualifiedPath()]);
throw new \Exception($msg);
}
}
}