Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions legacy/src/Command/Environment/EnvironmentLogCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ protected function configure(): void
$this->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->selector->addRemoteContainerOptions($this->getDefinition());
$this->selector->addTaskOption($this->getDefinition());
$this->addCompleter($this->selector);
$this->setHiddenAliases(['logs']);
$this->addExample('Display a choice of logs that can be read');
Expand Down
1 change: 1 addition & 0 deletions legacy/src/Command/Environment/EnvironmentScpCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ protected function configure(): void
$this->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->selector->addRemoteContainerOptions($this->getDefinition());
$this->selector->addTaskOption($this->getDefinition());
$this->addCompleter($this->selector);
Ssh::configureInput($this->getDefinition());
$this->addExample('Copy local files a.txt and b.txt to remote mount var/files', "a.txt b.txt remote:var/files");
Expand Down
2 changes: 2 additions & 0 deletions legacy/src/Command/Environment/EnvironmentSshCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -39,9 +39,11 @@ protected function configure(): void
$this->selector->addProjectOption($this->getDefinition());
$this->selector->addEnvironmentOption($this->getDefinition());
$this->selector->addRemoteContainerOptions($this->getDefinition());
$this->selector->addTaskOption($this->getDefinition());
$this->addCompleter($this->selector);
Ssh::configureInput($this->getDefinition());
$this->addExample('Open a shell over SSH');
$this->addExample('Open a shell into a running task container', '--task mytask');
$this->addExample('Pass an extra option to SSH', "-o 'RequestTTY force'");
$this->addExample('List files', 'ls');
$this->addExample("Monitor the app log (use '--' before flags)", 'tail /var/log/app.log -- -n50 -f');
Expand Down
97 changes: 97 additions & 0 deletions legacy/src/Model/RemoteContainer/Task.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
<?php

declare(strict_types=1);

namespace Platformsh\Cli\Model\RemoteContainer;

use GuzzleHttp\ClientInterface;
use GuzzleHttp\Utils;
use Platformsh\Cli\Model\AppConfig;
use Platformsh\Client\Model\Environment;

/**
* Represents a running task container, which allows SSH access.
*
* Unlike apps and workers, a task container is ephemeral: it only exists while
* an `environment.task` activity is in progress.
*/
readonly class Task implements RemoteContainerInterface
{
private string $containerName;

public function __construct(private Environment $environment, private string $taskName, string $activityId, private ClientInterface $client)
{
// The SSH gateway addresses a task container as
// "<task-name>--task--<first 8 chars of the activity ID>".
$this->containerName = $taskName . '--task--' . substr($activityId, 0, 8);
}

public function getSshUrl($instance = ''): string
{
// A task is a single ephemeral container; it has no instances, so the
// instance argument (which may be null) is ignored.
return $this->constructTaskSshUrl($this->referenceSshUrl(), $this->containerName);
}

public function getName(): string
{
return $this->containerName;
}

public function getConfig(): AppConfig
{
// The task's configuration (type, run, mounts, relationships, etc.) is
// exposed at /environments/<id>/tasks/<name>, independently of whether a
// run is in progress. Fetch it lazily, so callers that only need SSH
// access (ssh, log, scp) pay nothing.
$response = $this->client->request('GET', $this->environment->getUri() . '/tasks/' . rawurlencode($this->taskName));
$data = (array) Utils::jsonDecode((string) $response->getBody(), true);

return new AppConfig($data);
}

public function getRuntimeOperations(): array
{
return [];
}

/**
* Returns any of the environment's SSH URLs, to derive the host and user prefix from.
*/
private function referenceSshUrl(): string
{
$urls = $this->environment->getSshUrls();
if ($urls === []) {
throw new \RuntimeException(sprintf(
'Could not determine the SSH endpoint for the environment %s.',
$this->environment->id,
));
}
return reset($urls);
}

/**
* Builds the task container's SSH URL from a reference URL.
*
* A container SSH URL has the form "<project>-<environment>--<container>@<host>".
* All containers in an environment share the same "<project>-<environment>"
* user prefix and host, so the task URL is the reference URL with its
* container suffix replaced by the task's container name.
*/
private function constructTaskSshUrl(string $referenceUrl, string $containerName): string
{
$atPos = strrpos($referenceUrl, '@');
if ($atPos === false) {
throw new \RuntimeException(sprintf('Unexpected SSH URL format: %s', $referenceUrl));
}
$user = substr($referenceUrl, 0, $atPos);
$hostPart = substr($referenceUrl, $atPos + 1);

// The "--" double-dash separates the shared user prefix from the
// container name. Project IDs and environment machine names only use
// single dashes, so the prefix is everything before the first "--".
$prefix = explode('--', $user, 2)[0];

return $prefix . '--' . $containerName . '@' . $hostPart;
}
}
143 changes: 143 additions & 0 deletions legacy/src/Selector/Selector.php
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
use Platformsh\Cli\Local\ApplicationFinder;
use Platformsh\Cli\Model\Host\LocalHost;
use Platformsh\Cli\Model\RemoteContainer\BrokenEnv;
use Platformsh\Cli\Model\RemoteContainer\Task;
use Platformsh\Cli\Model\RemoteContainer\Worker;
use Platformsh\Cli\Model\RemoteContainer\App;
use GuzzleHttp\Exception\BadResponseException;
Expand All @@ -25,6 +26,7 @@
use Platformsh\Cli\Service\Identifier;
use Platformsh\Cli\Service\QuestionHelper;
use Platformsh\Client\Exception\EnvironmentStateException;
use Platformsh\Client\Model\Activity;
use Platformsh\Client\Model\BasicProjectInfo;
use Platformsh\Client\Model\Deployment\WebApp;
use Platformsh\Client\Model\Environment;
Expand Down Expand Up @@ -699,6 +701,25 @@ public function addRemoteContainerOptions(InputDefinition $definition): static
return $this;
}

/**
* Adds the --task option, to target a running task container.
*
* The option is resolved in selectRemoteContainer(). Only commands that
* operate over a plain SSH connection (e.g. ssh, log, scp) should add this:
* commands that need the container's deployment configuration (mounts, app
* type, runtime operations) cannot support ephemeral task containers.
*/
public function addTaskOption(InputDefinition $definition): static
{
if (!$definition->hasOption('task')) {
$definition->addOption(new InputOption('task', null, InputOption::VALUE_REQUIRED, 'The name of a running task (instead of an app or worker)'));
}
if (!$definition->hasOption('activity')) {
$definition->addOption(new InputOption('activity', null, InputOption::VALUE_REQUIRED, 'The ID of the task run to target (with --task), when multiple runs are in progress'));
}
return $this;
}

/**
* Find what app or worker container the user wants to select.
*
Expand All @@ -714,6 +735,13 @@ public function addRemoteContainerOptions(InputDefinition $definition): static
*/
private function selectRemoteContainer(Environment $environment, InputInterface $input, ?string $appName): RemoteContainerInterface
{
// A running task container is selected from its in-progress activity,
// not from the deployment, so handle it before loading the deployment.
$taskOption = $input->hasOption('task') ? $input->getOption('task') : null;
if ($taskOption !== null && $taskOption !== '') {
return $this->selectTaskContainer($environment, (string) $taskOption, $input);
}

$includeWorkers = $input->hasOption('worker');
try {
$deployment = $this->api->getCurrentDeployment(
Expand Down Expand Up @@ -859,6 +887,121 @@ private function selectRemoteContainer(Environment $environment, InputInterface
return new App($deployment->getWebApp($choice), $environment);
}

/**
* Selects the running task container for the --task option.
*
* A task container is ephemeral: it only exists while an environment.task
* activity is in progress. This finds the in-progress activity for the
* named task and builds a container from its ID.
*
* @throws InvalidArgumentException if no single running instance is found.
*/
private function selectTaskContainer(Environment $environment, string $taskName, InputInterface $input): RemoteContainerInterface
{
$running = array_values(array_filter(
$environment->getActivities(0, ['environment.task'], null, [Activity::STATE_IN_PROGRESS, Activity::STATE_PENDING]),
fn(Activity $activity): bool => ($activity->parameters['task'] ?? null) === $taskName,
));

if (count($running) === 0) {
throw new InvalidArgumentException($this->noRunningTaskMessage($environment, $taskName));
}

// An explicit activity ID disambiguates parallel runs without a prompt.
$activityId = $input->hasOption('activity') ? $input->getOption('activity') : null;
if ($activityId !== null && $activityId !== '') {
$activity = $this->matchTaskActivity($running, $taskName, (string) $activityId);
} elseif (count($running) === 1) {
$activity = reset($running);
} else {
$activity = $this->chooseTaskActivity($running, $taskName, $input);
}

$this->stdErr->writeln(
sprintf('Selected task: <info>%s</info> (activity: %s)', $taskName, $activity->id),
OutputInterface::VERBOSITY_VERBOSE,
);

return new Task($environment, $taskName, $activity->id, $this->api->getHttpClient());
}

/**
* Asks the user which running instance of a task to connect to.
*
* @param Activity[] $activities
*
* @throws InvalidArgumentException in non-interactive mode.
*/
private function chooseTaskActivity(array $activities, string $taskName, InputInterface $input): Activity
{
$choices = $byId = [];
foreach ($activities as $activity) {
$choices[$activity->id] = $activity->created_at !== ''
? sprintf('%s (started %s)', $activity->id, $activity->created_at)
: $activity->id;
Comment on lines +939 to +941
$byId[$activity->id] = $activity;
}
if (!$input->isInteractive()) {
throw new InvalidArgumentException(sprintf(
'Multiple instances of the task "%s" are running. Use --activity <id> to choose one, or run interactively. Activity IDs: %s',
$taskName,
implode(', ', array_keys($byId)),
));
}
$id = $this->questionHelper->choose($choices, sprintf('Enter a number to choose which run of "%s" to connect to:', $taskName));

return $byId[$id];
}

/**
* Matches a running task activity by its ID (exact, or a unique prefix).
*
* @param Activity[] $running
*
* @throws InvalidArgumentException if the ID matches no, or more than one, running activity.
*/
private function matchTaskActivity(array $running, string $taskName, string $activityId): Activity
{
$matches = array_values(array_filter(
$running,
fn(Activity $activity): bool => $activity->id === $activityId,
));
if (count($matches) === 1) {
return $matches[0];
}
$ids = implode(', ', array_map(fn(Activity $activity): string => $activity->id, $running));
throw new InvalidArgumentException(sprintf(
'No running instance of the task "%s" matches the activity ID "%s". Running activity IDs: %s',
$taskName,
$activityId,
$ids,
));
}
Comment on lines +963 to +979

/**
* Builds a helpful message for when a task has no running instance.
*/
private function noRunningTaskMessage(Environment $environment, string $taskName): string
{
// The activities API returns the most recent activities first.
$recent = array_values(array_filter(
$environment->getActivities(10, ['environment.task']),
fn(Activity $activity): bool => ($activity->parameters['task'] ?? null) === $taskName,
));
if ($recent === []) {
return sprintf('No task named "%s" has run on the environment %s.', $taskName, $environment->id);
}
$last = reset($recent);
$when = $last->completed_at ?: $last->created_at;

return sprintf(
'The task "%s" has no running instance (last run: activity %s%s). SSH is only possible while a task is running.',
$taskName,
$last->id,
$when !== '' ? ', finished ' . $when : '',
);
}

/**
* Adds the --org (-o) organization name option.
*
Expand Down