From 404262e9a71ab2e1023b2639d2f1af9849ff9e34 Mon Sep 17 00:00:00 2001 From: Veronika Tolkachova Date: Tue, 23 Jun 2026 14:21:20 +0200 Subject: [PATCH 1/2] feat: implement tasks container selector --- legacy/src/Model/RemoteContainer/Task.php | 97 +++++++++++++++ legacy/src/Selector/Selector.php | 143 ++++++++++++++++++++++ 2 files changed, 240 insertions(+) create mode 100644 legacy/src/Model/RemoteContainer/Task.php diff --git a/legacy/src/Model/RemoteContainer/Task.php b/legacy/src/Model/RemoteContainer/Task.php new file mode 100644 index 000000000..df66451e4 --- /dev/null +++ b/legacy/src/Model/RemoteContainer/Task.php @@ -0,0 +1,97 @@ +--task--". + $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//tasks/, 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 "---@". + * All containers in an environment share the same "-" + * 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; + } +} diff --git a/legacy/src/Selector/Selector.php b/legacy/src/Selector/Selector.php index b8dbe834f..2d82b25dc 100644 --- a/legacy/src/Selector/Selector.php +++ b/legacy/src/Selector/Selector.php @@ -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; @@ -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; @@ -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. * @@ -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( @@ -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: %s (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; + $byId[$activity->id] = $activity; + } + if (!$input->isInteractive()) { + throw new InvalidArgumentException(sprintf( + 'Multiple instances of the task "%s" are running. Use --activity 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, + )); + } + + /** + * 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. * From 4494ce885093f2e9c6d0eccadbb4dfb12c78ad20 Mon Sep 17 00:00:00 2001 From: Veronika Tolkachova Date: Tue, 23 Jun 2026 14:21:58 +0200 Subject: [PATCH 2/2] feat: use task container selector for ssh commands --- legacy/src/Command/Environment/EnvironmentLogCommand.php | 1 + legacy/src/Command/Environment/EnvironmentScpCommand.php | 1 + legacy/src/Command/Environment/EnvironmentSshCommand.php | 2 ++ 3 files changed, 4 insertions(+) diff --git a/legacy/src/Command/Environment/EnvironmentLogCommand.php b/legacy/src/Command/Environment/EnvironmentLogCommand.php index 60aeb1f08..cca323b6c 100644 --- a/legacy/src/Command/Environment/EnvironmentLogCommand.php +++ b/legacy/src/Command/Environment/EnvironmentLogCommand.php @@ -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'); diff --git a/legacy/src/Command/Environment/EnvironmentScpCommand.php b/legacy/src/Command/Environment/EnvironmentScpCommand.php index 2bfbbd73a..f9a0d1449 100644 --- a/legacy/src/Command/Environment/EnvironmentScpCommand.php +++ b/legacy/src/Command/Environment/EnvironmentScpCommand.php @@ -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"); diff --git a/legacy/src/Command/Environment/EnvironmentSshCommand.php b/legacy/src/Command/Environment/EnvironmentSshCommand.php index 230eeb2dc..6db3236b9 100644 --- a/legacy/src/Command/Environment/EnvironmentSshCommand.php +++ b/legacy/src/Command/Environment/EnvironmentSshCommand.php @@ -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');