Skip to content

Commit

Permalink
Added Acquia CLI target.
Browse files Browse the repository at this point in the history
  • Loading branch information
fiasco committed Feb 18, 2024
1 parent 4c951d4 commit dcf17da
Show file tree
Hide file tree
Showing 7 changed files with 392 additions and 1 deletion.
7 changes: 7 additions & 0 deletions drutiny.yml
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ services:

Drutiny\Acquia\AcquiaTarget:
tags: [target]
Drutiny\Acquia\AcliTarget:
tags: [target]

Drutiny\Acquia\Api\Analytics:

Expand All @@ -24,6 +26,11 @@ services:
acquia.cloud.api:
class: Drutiny\Acquia\Api\CloudApi

Drutiny\Acquia\Api\CloudApiInterface:
class: Drutiny\Acquia\Api\CloudApi

Drutiny\Acquia\Api\AcquiaCli:

acquia.lift.api:
class: Drutiny\Acquia\Api\LiftProfileManagerClient
factory: ['Drutiny\Acquia\Api\LiftProfileManagerClient', 'create']
Expand Down
159 changes: 159 additions & 0 deletions src/AcliTarget.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
<?php

namespace Drutiny\Acquia;

use Drutiny\Target\Exception\InvalidTargetException;
use Drutiny\Target\TargetSourceInterface;
use Drutiny\Target\TargetInterface;
use Drutiny\Acquia\Api\AcquiaCli;
use Drutiny\Attribute\AsTarget;
use Drutiny\Attribute\UseService;
use Drutiny\Target\DrushTarget;
use Drutiny\Target\Exception\TargetServiceUnavailable;
use Drutiny\Target\Service\Drush;
use Drutiny\Target\Service\ServiceInterface;
use Drutiny\Target\Transport\SshTransport;
use Drutiny\Target\Transport\TransportInterface;

/**
* Acquia Target
*/
#[UseService(AcquiaCli::class, 'setAcquiaCli')]
#[AsTarget(name: 'acli')]
class AcliTarget extends DrushTarget implements TargetSourceInterface
{
protected AcquiaCli $api;

/**
* {@inheritdoc}
*/
public function getId():string
{
return $this['alias'];
}

/**
* Setter callback for Acquia CLI service.
*/
public function setAcquiaCli(AcquiaCli $api) {
$this->api = $api;
}

/**
* {@inheritDoc}
*/
protected function configureService(ServiceInterface $service): void
{
// If there is no Drupal site, then there is no point in running a drush service.
if ($service instanceof Drush && $this['acquia.cloud.environment.vcs[path]'] == 'tags/WELCOME') {
throw new TargetServiceUnavailable("Acquia CLI target environment has the WELCOME tag deployed. There is no Drupal here.");
}
parent::configureService($service);
}

/**
* Parse target data.
*/
public function parse(string $alias, ?string $uri = null): TargetInterface
{
if (str_starts_with($alias, '@')) {
$alias = substr($alias, 1);
}
if (!str_contains($alias, '.')) {
throw new InvalidTargetException("Incorrect format. Acquia CLI target must use alias syntax: appname.env");
}
$this['alias'] = $alias;
list($app, $env) = explode('.', $alias);

$realm = 'prod';
if (str_contains($app, ':')) {
list($realm, $app) = explode(':', $app);
}

$this['alias'] = $alias;

$this->logger->info('Loading environment from API...');
$application = $this->api->findApplication($realm, $app);
$environment = $this->api->findEnvironment($application['uuid'], $env);

$this->api->mapToTarget($application, $this, 'acquia.cloud.application');
$this->api->mapToTarget($environment, $this, 'acquia.cloud.environment');

list($machine_name, ) = explode('.', $environment['default_domain']);
$this['acquia.cloud.machine_name'] = $machine_name;

$this->setUri($uri ?: $environment['active_domain']);

if ($environment['type'] == 'drupal') {
list($user, $host) = explode('@', $environment['ssh_url'], 2);
// At the remote, this is what the Drush alias should be. This becomes
// an environment variable (e.g. $DRUSH_ALIAS).
$this['drush.alias'] = "@$user";
// Tell DrushService where the drupal site is.
$this['drush.root'] = "/var/www/html/$user/docroot";

// We need to load the Transport early to load drush attributes correctly.
$this->transport = $this->loadTransport();
$this->rebuildEnvVars();

if ($this['acquia.cloud.environment.vcs[path]'] == 'tags/WELCOME') {
$this->setUri($uri ?: $environment['default_domain']);
$this->logger->critical("Acquia target environment has the WELCOME tag deployed. There is no Drupal here.");
return $this;
}

$this->buildAttributes();
}

return $this;
}

/**
* {@inheritdoc}
*/
public function getAvailableTargets():array
{
$targets = [];
$applications = $this->api->runApiCommand('api:applications:list');
foreach ($applications as $app) {
$this->logger->notice("Building environment targets for {$app['name']}.");

$environments = $this->api->runApiCommand('api:applications:environment-list', [$app['uuid']]);
foreach ($environments as $env) {
$targets[] = [
'id' => $app['hosting']['id'] . '.' . $env['name'],
'uri' => $env['active_domain'],
'name' => sprintf('%s: %s', $env['label'], $app['name']),
];
}
}
return $targets;
}

/**
* Setup SSH based remote exection service.
*/
protected function addRemoteTransport($user, $host):TransportInterface
{
// Don't need to setup transport if its already handled be an extending class.
if ($this->transport instanceof SshTransport) {
return $this->transport;
}
$transport = new SshTransport($this->localCommand);
$transport->setConfig('User', $user);
$transport->setConfig('Host', $host);
return $transport;
}

/**
* {@inheritdoc}
*/
protected function loadTransport(): TransportInterface
{
if ($this['acquia.cloud.environment']['type'] == 'drupal') {
list($user, $host) = explode('@', $this['acquia.cloud.environment']['ssh_url'], 2);
return $this->addRemoteTransport($user, $host);
}
return parent::loadTransport();
}
}
3 changes: 3 additions & 0 deletions src/AcquiaTarget.php
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,9 @@ protected function addRemoteTransport($user, $host):TransportInterface
return $transport;
}

/**
* {@inheritdoc}
*/
protected function loadTransport(): TransportInterface
{
if ($this['acquia.cloud.environment']['type'] == 'drupal') {
Expand Down
170 changes: 170 additions & 0 deletions src/Api/AcquiaCli.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
<?php

namespace Drutiny\Acquia\Api;

use Drutiny\Entity\Exception\DataNotFoundException;
use Drutiny\Helper\TextCleaner;
use Drutiny\Target\TargetInterface;
use InvalidArgumentException;
use Psr\Cache\CacheItemInterface;
use Symfony\Component\Process\Process;
use Symfony\Contracts\Cache\CacheInterface;

class AcquiaCli implements CloudApiInterface {

const DEFAULT_LOCATIONS = [
'vendor/bin',
'/usr/local/bin',
'$HOME/.composer/vendor/bin',
'/usr/bin',
'/bin',
];

protected string $bin;

public function __construct(protected CacheInterface $cache) {
// Look for a local version of ACLI.
$bins = array_filter(self::DEFAULT_LOCATIONS, function ($dir) {
return file_exists($dir . '/acli');
});
if (count($bins) > 1) {
$bins = array_filter($bins, fn ($bin) => is_executable("$bin/acli"));
}
if (!empty($bins)) {
$this->bin = reset($bins) . "/acli";
}
}

public function isAvailable(): bool {
return isset($this->bin) && !empty($this->bin);
}

/**
* {@inheritdoc}
*/
public function findApplication($realm, $site):array
{
$apps = $this->cache->get('acquia.cloud.applications', function (CacheItemInterface $item) {
$item->expiresAfter(86400);
$output = $this->prepareCommand('api:applications:list')->mustRun()->getOutput();
return json_decode($output, TRUE);
});

foreach ($apps as $app) {
if (empty($app['hosting'])) {
continue;
}
list($stage, $sitegroup) = explode(':', $app['hosting']['id'], 2);

if ($realm == $stage && $site == $sitegroup) {
return $app;
}
}
throw new DataNotFoundException("Cannot find Acquia application matching target criteria: $realm:$site.");
}

/**
* {@inheritdoc}
*/
public function findEnvironment($uuid, $env):array
{
foreach ($this->getEnvironments($uuid) as $environment) {
if ($environment['name'] == $env) {
return $environment;
}
}
throw new DataNotFoundException("Cannot find Acquia application environment: $env.");
}

/**
* {@inheritdoc}
*/
public function getEnvironments(string $uuid): array {
return $this->cache->get('acquia.cloud.'.$uuid.'.environments', function (CacheItemInterface $item) use ($uuid) {
$item->expiresAfter(86400);
return $this->runApiCommand('api:applications:environment-list', [$uuid]);
});
}

/**
* {@inheritdoc}
*/
public function getEnvironment($uuid):array
{
return $this->cache->get('acquia.cloud.environment.'.$uuid, function (CacheItemInterface $item) use ($uuid) {
$item->expiresAfter(86400);
return $this->runApiCommand('api:environments:find', [$uuid]);
});
}

/**
* {@inheritdoc}
*/
public function getApplication($uuid):array
{
return $this->cache->get('acquia.cloud.application.'.$uuid, function (CacheItemInterface $item) use ($uuid) {
$item->expiresAfter(86400);
return $this->runApiCommand('api:applications:find', [$uuid]);
});
}

/**
* {@inheritdoc}
*/
public function mapToTarget(array $data, TargetInterface $target, $namespace):void
{
foreach ($data as $key => $value) {
if ('_' == substr($key, 0, 1)) {
continue;
}
$target[$namespace.'.'.$key] = $value;
}
}

/**
* Run an Acquia CLI API command and return the json decoded response.
*/
public function runApiCommand(string $command, array $args = [], array $options = []): array {
if (!str_starts_with($command, 'api')) {
throw new InvalidArgumentException("Command is not allowed. Must use and api: prefixed command.");
}
if (!$this->isAvailable()) {
throw new AcquiaCliNotAvailableException();
}
$cid = md5(json_encode([$command, $args, $options]));
$process = $this->prepareCommand($command, $args, $options);
return $this->cache->get($cid, function (CacheItemInterface $item) use ($process) {
$item->expiresAfter(3600);
$output = $process->mustRun()->getOutput();
return TextCleaner::decodeDirtyJson($output);
});
}

/**
* Prepare a command to run with Acquia CLI.
*/
protected function prepareCommand(string $command, array $args = [], array $options = []):Process {
$cmd = $this->bin . ' ' . $command . ' ' . implode(' ', $args);

$opts = [];
foreach ($options as $name => $value) {
$opt_type = (strlen($name) > 1) ? '--' : '-';
// Flag opts.
if (is_null($value) || is_bool($value)) {
$opts[] = $opt_type . $name;
continue;
}
if ($opt_type == '--') {
$opts[] = $opt_type . '=' . $value;
}
else {
$opts[] = $opt_type . ' ' . $value;
}
}
if (!empty($opts)) {
$cmd .= ' ' . implode(' ', $opts);
}

return Process::fromShellCommandline($cmd);
}
}
10 changes: 10 additions & 0 deletions src/Api/AcquiaCliNotAvailableException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
<?php

namespace Drutiny\Acquia\Api;

class AcquiaCliNotAvailableException extends \Exception {
public function __construct()
{
parent::__construct("Acquia CLI could not be found. Please ensure Acquia CLI is installed locally on your system.");
}
}
2 changes: 1 addition & 1 deletion src/Api/CloudApi.php
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ class: AcquiaCloudPlugin::class
description: 'Your API secret to connect to the Acquia Cloud API v2 with:',
type: FieldType::CREDENTIAL
)]
class CloudApi extends Connector
class CloudApi extends Connector implements CloudApiInterface
{
public function __construct(
protected AcquiaCloudPlugin $plugin,
Expand Down
Loading

0 comments on commit dcf17da

Please sign in to comment.