Files
cacti/lib/Console/Command/LegacyScriptCommand.php
Thomas Vincent 1ae479d4f5 feat(cli): add unified command frontend and normalize legacy failures (#7903)
* feat(cli): add unified command frontend and normalize legacy failures

Add the Symfony-backed bin/cacti entry point while preserving legacy argument, terminal, signal, and exit-status behavior. Fold in the focused legacy CLI correctness fixes from #7906 without adding its unused parser abstraction.

Keep path containment and the PHP 8.3 floor out of this review; the latter remains in #7939.

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>

* fix(cli): enforce PHP floor and deny web dispatch

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
2026-09-07 18:36:55 -07:00

150 lines
5.9 KiB
PHP

<?php
/*
+-------------------------------------------------------------------------+
| Copyright (C) 2004-2026 The Cacti Group |
| |
| This program is free software; you can redistribute it and/or |
| modify it under the terms of the GNU General Public License |
| as published by the Free Software Foundation; either version 2 |
| of the License, or (at your option) any later version. |
+-------------------------------------------------------------------------+
| Cacti: The Complete RRDtool-based Graphing Solution |
+-------------------------------------------------------------------------+
*/
declare(strict_types = 1);
namespace Cacti\Console\Command;
use Cacti\Console\Input\RawArgvInput;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\ConsoleOutputInterface;
use Symfony\Component\Console\Output\OutputInterface;
use Symfony\Component\Process\Exception\ProcessSignaledException;
use Symfony\Component\Process\Exception\LogicException as ProcessLogicException;
use Symfony\Component\Process\Process;
final class LegacyScriptCommand extends Command {
/** @var \Closure(array<int, string>): Process */
private readonly \Closure $process_factory;
/** @var \Closure(): bool */
private readonly \Closure $tty_probe;
/** @var (\Closure(int, callable): void)|null */
private readonly ?\Closure $signal_registrar;
public function __construct(
string $name,
private readonly string $script,
private readonly string $root,
?callable $process_factory = null,
?callable $tty_probe = null,
callable|false|null $signal_registrar = null,
) {
parent::__construct($name);
$this->process_factory = $process_factory === null
? static fn (array $command): Process => new Process($command, null, null, STDIN, null)
: \Closure::fromCallable($process_factory);
$this->tty_probe = $tty_probe === null
? static fn (): bool => defined('STDIN') && defined('STDOUT') && defined('STDERR') && Process::isTtySupported() && stream_isatty(STDIN) && stream_isatty(STDOUT) && stream_isatty(STDERR)
: \Closure::fromCallable($tty_probe);
$signals_available = function_exists('pcntl_async_signals') && function_exists('pcntl_signal');
if ($signal_registrar === false || ($signal_registrar === null && !$signals_available)) {
$this->signal_registrar = null;
} elseif ($signal_registrar !== null) {
$this->signal_registrar = \Closure::fromCallable($signal_registrar);
} else {
$this->signal_registrar = static function (int $signal, callable $handler): void {
pcntl_async_signals(true);
pcntl_signal($signal, $handler);
};
}
$this->setAliases([$script]);
$this->setDescription(sprintf('Runs the legacy cli/%s.php command.', $script));
$this->setHelp(sprintf(
'This compatibility command forwards arguments, input, output, and the exit status to cli/%s.php.',
$script
));
$this->ignoreValidationErrors();
}
#[\Override]
protected function execute(InputInterface $input, OutputInterface $output): int {
if (!$input instanceof RawArgvInput) {
throw new \LogicException('Legacy commands require RawArgvInput.');
}
$arguments = $input->argumentsAfterCommand(array_values(array_merge([$this->getName()], $this->getAliases())));
/* Inherit the caller's directory. The script path is absolute, and the
* legacy scripts resolve relative path arguments against the cwd with no
* normalization. Anchoring here would send a relative output into the
* web-served root instead of the directory the operator ran it from. */
$process = ($this->process_factory)(
array_merge([PHP_BINARY, $this->root . '/cli/' . $this->script . '.php'], $arguments)
);
$process->setTimeout(null);
$this->forwardSignals($process);
try {
/* Several scripts under cli/ prompt with fgets(STDIN). Behind a pipe
* the prompt sits in a buffer while the child blocks on the read, so
* hand the terminal over whenever there is one. */
if ($this->canUseTty()) {
$process->setTty(true);
return $process->run();
}
/* Process::buildCallback() appends every byte to a php://temp stream
* as well as calling this callback, and nothing ever reads it back.
* With no timeout, a long poller run spooled its whole output to disk
* for no reader. */
$process->disableOutput();
return $process->run(function (string $type, string $buffer) use ($output): void {
$target = $type === Process::ERR && $output instanceof ConsoleOutputInterface
? $output->getErrorOutput()
: $output;
$target->write($buffer, false, OutputInterface::OUTPUT_RAW);
});
} catch (ProcessSignaledException $exception) {
/* A child the kernel killed has no exit code of its own, and Process
* throws rather than returning one. Report it the way a shell does so
* cron and systemd see 137 or 143, not a rendered stack trace. */
return 128 + $exception->getSignal();
}
}
/**
* Whether this invocation owns a terminal it can hand to the child.
*/
private function canUseTty(): bool {
return ($this->tty_probe)();
}
/**
* Pass a termination signal on to the child.
*
* Without this a SIGTERM to bin/cacti leaves the legacy script running,
* possibly mid-write to the database, with nothing left to reap it.
*/
private function forwardSignals(Process $process): void {
if ($this->signal_registrar === null) {
return;
}
foreach ([SIGTERM, SIGINT, SIGHUP] as $signal) {
($this->signal_registrar)($signal, static function (int $received) use ($process): void {
try {
$process->signal($received);
} catch (ProcessLogicException) {
/* The child can exit between signal delivery and Process::signal().
* Its real exit status remains authoritative in that race. */
}
});
}
}
}