Skip to content
Merged
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
11 changes: 11 additions & 0 deletions components/ILIAS/Setup/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -326,6 +326,17 @@ system where ILIAS was not installed, for example, the output only contains the
information that ilias is not installed. The command also reports on the configuration
of the installation.

Use the `-f` option to only output a part of the status. The filter is the path to
a metric, separated by dots, e.g. `php cli/setup.php status -f database.version`.
The first part of the path names an agent, and only that agent is asked for its
status. For an agent of the core, the agents of the plugins are not even looked up,
so a filtered call is considerably faster than a complete one.
Metrics of the configuration are shown in their own section `config`, which is why
a filter starting with `config.` looks into that section, e.g.
`php cli/setup.php status -f config.common` only outputs the common configuration.
Consequently, `config` cannot be used as the key of an agent.
If the path does not exist, the command says so and exits with a non-zero code.

The output of the command is formatted as YAML to be easily readable by people and
machines. So we encourage you to use this command for monitoring your system and
also request status information via our feature process that you are interested in.
Expand Down
14 changes: 14 additions & 0 deletions components/ILIAS/Setup/src/AgentCollection.php
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,20 @@ public function withRemovedAgent(string $key): AgentCollection
return $clone;
}

/**
* A collection that only contains the agent with the given key, still under
* that key, so everything it stores keeps its path.
*/
public function withOnlyAgent(string $key): AgentCollection
{
if (!isset($this->agents[$key])) {
throw new \InvalidArgumentException("There is no agent with the name '$key'.");
}
$clone = clone $this;
$clone->agents = [$key => $this->agents[$key]];
return $clone;
}

public function withAdditionalAgent(string $key, Agent $agent): AgentCollection
{
if (isset($this->agents[$key])) {
Expand Down
28 changes: 28 additions & 0 deletions components/ILIAS/Setup/src/CLI/MetricsFilterException.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php

/**
* This file is part of ILIAS, a powerful learning management system
* published by ILIAS open source e-Learning e.V.
*
* ILIAS is licensed with the GPL-3.0,
* see https://www.gnu.org/licenses/gpl-3.0.en.html
* You should have received a copy of said license along with the
* source code, too.
*
* If this is not the case or you just want to try ILIAS, you'll find
* us at:
* https://www.ilias.de
* https://github.com/ILIAS-eLearning
*
*********************************************************************/

declare(strict_types=1);

namespace ILIAS\Setup\CLI;

/**
* Thrown when the filter of the status command does not match the collected metrics.
*/
class MetricsFilterException extends \RuntimeException
{
}
123 changes: 112 additions & 11 deletions components/ILIAS/Setup/src/CLI/StatusCommand.php
Original file line number Diff line number Diff line change
Expand Up @@ -20,12 +20,14 @@

namespace ILIAS\Setup\CLI;

use ILIAS\Setup\AgentCollection;
use ILIAS\Setup\AgentFinder;
use ILIAS\Setup\ArrayEnvironment;
use ILIAS\Setup\Objective\Tentatively;
use ILIAS\Setup\Metrics;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;
use ILIAS\Setup\Agent;

Expand All @@ -49,19 +51,80 @@ protected function configure(): void
{
$this->setDescription("Collect and show status information about the installation.");
$this->configureCommandForPlugins();
$this->addOption(
"filter",
"f",
InputOption::VALUE_REQUIRED,
"Only show the metrics on this path, e.g. \"database\" or \"config.common\"."
);
}


protected function execute(InputInterface $input, OutputInterface $output): int
{
$agent = $this->getRelevantAgent($input);
$filter = (string) ($input->getOption("filter") ?? "");

try {
$metrics = $this->getMetrics($this->getAgentFor($input, $filter), $filter);
} catch (MetricsFilterException $e) {
$output->writeln("<error>" . $e->getMessage() . "</error>");
return Command::FAILURE;
}

$output->write($metrics->toYAML() . "\n");

return Command::SUCCESS;
}

/**
* The first part of a filter names an agent. Only that agent is built and asked
* for its status, which spares the others and, for an agent of the core, even
* finding the agents of the plugins.
*/
protected function getAgentFor(InputInterface $input, string $filter): Agent
{
$key = $this->getAgentKeyOf($filter);
if ($key === null
|| ($input->hasOption("legacy-plugin") && $input->getOption("legacy-plugin"))) {
return $this->getRelevantAgent($input);
}

$component_agents = $this->agent_finder->getComponentAgents();
if ($component_agents->getAgent($key) !== null) {
return $component_agents->withOnlyAgent($key);
}

$agents = $this->getRelevantAgent($input);
if (!$agents instanceof AgentCollection) {
return $agents;
}
if ($agents->getAgent($key) === null) {
throw new MetricsFilterException(
"There is no metric '$key'. Run the command without a filter to see which metrics are available."
);
}
return $agents->withOnlyAgent($key);
}

$output->write($this->getMetrics($agent)->toYAML() . "\n");
protected function getAgentKeyOf(string $filter): ?string
{
$path = $this->getPathOf($filter);
return $path === [] ? null : $path[0];
}

return 0;
/**
* @return string[]
*/
protected function getPathOf(string $filter): array
{
$path = array_values(array_filter(explode(".", $filter), fn(string $p): bool => $p !== ""));
if (($path[0] ?? null) === "config") {
array_shift($path);
}
return $path;
}

public function getMetrics(Agent $agent): Metrics\Metric
public function getMetrics(Agent $agent, string $filter = ""): Metrics\Metric
{
// ATTENTION: Don't do this (in general), please have a look at the comment
// in ilIniFilesLoadedObjective.
Expand All @@ -75,21 +138,59 @@ public function getMetrics(Agent $agent): Metrics\Metric

$this->achieveObjective($objective, $environment);

$metric = $storage->asMetric();
$show_config_section = explode(".", $filter)[0] === "config";
$metric = $this->select($this->getPathOf($filter), $storage->asMetric());

$type = Metrics\Metric::TYPE_COLLECTION;
list($config, $other) = $metric->extractByStability(Metrics\Metric::STABILITY_CONFIG);
if ($other) {
$values = [];
if ($other && !$show_config_section) {
$values = $other->getValue();
} else {
$values = [];
$type = $other->getType();
}
if ($config) {
$values["config"] = $config;
if ($show_config_section) {
$values = $config->getValue();
$type = $config->getType();
} else {
$values["config"] = $config;
}
}

return new Metrics\Metric(
Metrics\Metric::STABILITY_MIXED,
Metrics\Metric::TYPE_COLLECTION,
$type === Metrics\Metric::TYPE_COLLECTION
? Metrics\Metric::STABILITY_MIXED
: Metrics\Metric::STABILITY_VOLATILE,
$type,
$values
);
}

/**
* @param string[] $path
*/
protected function select(array $path, Metrics\Metric $metric): Metrics\Metric
{
$done = [];
foreach ($path as $key) {
if ($metric->getType() !== Metrics\Metric::TYPE_COLLECTION) {
throw new MetricsFilterException(
"Cannot filter by '$key': "
. ($done === [] ? "the status" : "'" . implode(".", $done) . "'")
. " is a single value, not a collection."
);
}
$metrics = $metric->getValue();
if (!array_key_exists($key, $metrics)) {
throw new MetricsFilterException(
"There is no metric '" . implode(".", [...$done, $key]) . "'. Available"
. ($done === [] ? "" : " below '" . implode(".", $done) . "'")
. ": " . implode(", ", array_keys($metrics))
);
}
$metric = $metrics[$key];
$done[] = $key;
}
return $metric;
}
}
82 changes: 59 additions & 23 deletions components/ILIAS/Setup/src/ImplementationOfAgentFinder.php
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,21 @@

class ImplementationOfAgentFinder implements AgentFinder
{
protected const string PLUGIN_PATH = "[/]public/Customizing/global/plugins/";

protected array|AgentCollection $component_agents;
protected string $plugin_directory;

public function __construct(
protected Refinery $refinery,
protected Data\Factory $data_factory,
protected \ILIAS\Language\Language $lng,
protected ImplementationOfInterfaceFinder $interface_finder,
$component_agents
$component_agents,
?string $plugin_directory = null
) {
$this->component_agents = $component_agents;
$this->plugin_directory = $plugin_directory ?? __DIR__ . "/../../../../public/Customizing/global/plugins";
}

/**
Expand All @@ -46,13 +51,20 @@ public function getAgents(): AgentCollection
{
$agents = $this->getComponentAgents();

// Get a list of existing plugins in the system.
$plugins = $this->getPluginNames();
// Searching the classes of all plugins at once, a search per plugin would
// go through every class of the system once for every plugin.
$agent_classes_by_plugin = [];
foreach ($this->interface_finder->getMatchingClassNames(Agent::class, [], self::PLUGIN_PATH . ".*") as $class_name) {
$plugin_name = $this->getPluginNameOfClass($class_name);
if ($plugin_name !== null) {
$agent_classes_by_plugin[$plugin_name][] = $class_name;
}
}

foreach ($plugins as $plugin_name) {
foreach ($this->getPluginNames() as $plugin_name) {
$agents = $agents->withAdditionalAgent(
$plugin_name,
$this->getPluginAgent($plugin_name)
$this->buildPluginAgent($plugin_name, $agent_classes_by_plugin[$plugin_name] ?? [])
);
}

Expand Down Expand Up @@ -98,13 +110,23 @@ public function getPluginAgent(string $name): Agent
// TODO: This seems to be something that rather belongs to Services/Component/
// but we put it here anyway for the moment. This seems to be something that
// could go away when we unify Services/Modules/Plugins to one common concept.
$path = "[/]public/Customizing/global/plugins/.*/.*/" . $name . "/.*";
$agent_classes = iterator_to_array($this->interface_finder->getMatchingClassNames(
Agent::class,
[],
$path
));
$agent_classes = array_filter(
iterator_to_array($this->interface_finder->getMatchingClassNames(
Agent::class,
[],
self::PLUGIN_PATH . ".*/.*/" . $name . "/.*"
)),
fn(string $class_name): bool => $this->getPluginNameOfClass($class_name) === $name
);

return $this->buildPluginAgent($name, array_values($agent_classes));
}

/**
* @param string[] $agent_classes
*/
protected function buildPluginAgent(string $name, array $agent_classes): Agent
{
if ($agent_classes === []) {
return new class ($name) extends \ilPluginDefaultAgent {
};
Expand Down Expand Up @@ -133,6 +155,20 @@ public function getPluginAgent(string $name): Agent
return new \ilPluginAgentWithDefaultObjectives($name, $agent);
}

/**
* The name of the plugin a class belongs to, or null if it is no class of a plugin.
*/
protected function getPluginNameOfClass(string $class_name): ?string
{
$file = (new \ReflectionClass($class_name))->getFileName();
$groups = [];
if ($file !== false
&& preg_match("%/Customizing/global/plugins/(?:Services|Modules)/\\w+/\\w+/([^/.]+)/%", $file, $groups)) {
return $groups[1];
}
return null;
}

public function getAgentByClassName(string $class_name): Agent
{
if (!class_exists($class_name)) {
Expand Down Expand Up @@ -165,21 +201,21 @@ public function getAgentNameByClassName(string $class_name): string
*/
protected function getPluginNames(): \Generator
{
$directories =
new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator(__DIR__ . "/../../../../public/Customizing/global/plugins")
);
// Plugins live exactly four levels below the plugin directory, so there is
// no need to walk through every file of every plugin (including their
// vendor directories) to find them.
$directories = array_merge(
glob($this->plugin_directory . "/Services/*/*/*", GLOB_ONLYDIR) ?: [],
glob($this->plugin_directory . "/Modules/*/*/*", GLOB_ONLYDIR) ?: []
);
$names = [];
foreach ($directories as $dir) {
$groups = [];
if (preg_match("%^" . __DIR__ . "/[.][.]/[.][.]/[.][.]/[.][.]/public/Customizing/global/plugins/(Services|Modules)/((\\w+/){2})([^/\.]+)(/|$)%", (string) $dir, $groups)) {
$name = $groups[4];
if (isset($names[$name])) {
continue;
}
$names[$name] = true;
yield $name;
$name = basename($dir);
if (!preg_match("%/\\w+/\\w+/[^/.]+$%", $dir) || isset($names[$name])) {
continue;
}
$names[$name] = true;
yield $name;
}
}
}
22 changes: 22 additions & 0 deletions components/ILIAS/Setup/tests/AgentCollectionTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -514,4 +514,26 @@ public function testGetAgents(): void
"c4" => $c4
], $agentCollection->getAgents());
}

public function testWithOnlyAgentKeepsTheAgentUnderItsKey(): void
{
$refinery = new Refinery($this->createStub(DataFactory::class), $this->createStub(\ilLanguage::class));
$database = $this->createStub(Setup\Agent::class);
$language = $this->createStub(Setup\Agent::class);
$collection = new Setup\AgentCollection($refinery, ['database' => $database, 'language' => $language]);

$only = $collection->withOnlyAgent('database');

$this->assertSame(['database' => $database], $only->getAgents());
$this->assertSame(['database' => $database, 'language' => $language], $collection->getAgents());
}

public function testWithOnlyAgentRejectsAnUnknownKey(): void
{
$refinery = new Refinery($this->createStub(DataFactory::class), $this->createStub(\ilLanguage::class));
$collection = new Setup\AgentCollection($refinery, []);

$this->expectException(\InvalidArgumentException::class);
$collection->withOnlyAgent('database');
}
}
Loading
Loading