From cb332d24e54c58f12248ef3cee8d0678724fe6c5 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 26 Oct 2026 07:49:27 +0000 Subject: [PATCH 1/5] fix(autoscaling): type the settings, and read partial ones The autoscaling settings are now typed once, where they come back from the API (Model\AutoscalingSettings). Missing values and values of an unexpected type are left out, and the commands handle their absence: - autoscaling:set no longer fails with a TypeError when summarizing a change to a service whose settings omit a trigger's duration, a cooldown or an instance limit, e.g. after a partial update. Defaults may omit values too. - autoscaling:get shows whether a trigger is enabled in each direction's row. It was only set in rows after the "enabled" key, so it depended on the order of the API's keys. Directions are listed up, then down. Co-Authored-By: Claude Opus 5.5 --- integration-tests/autoscaling_get_test.go | 74 ++++++ legacy/phpstan-baseline.neon | 220 +----------------- .../AutoscalingSettingsGetCommand.php | 24 +- .../AutoscalingSettingsSetCommand.php | 67 +++--- .../Command/Resources/ResourcesGetCommand.php | 2 +- .../Command/Resources/ResourcesSetCommand.php | 2 +- legacy/src/Model/AutoscalingSettings.php | 119 ++++++++++ legacy/src/Service/Api.php | 8 +- 8 files changed, 256 insertions(+), 260 deletions(-) create mode 100644 integration-tests/autoscaling_get_test.go create mode 100644 legacy/src/Model/AutoscalingSettings.php diff --git a/integration-tests/autoscaling_get_test.go b/integration-tests/autoscaling_get_test.go new file mode 100644 index 000000000..2e8d854d0 --- /dev/null +++ b/integration-tests/autoscaling_get_test.go @@ -0,0 +1,74 @@ +package tests + +import ( + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/upsun/cli/pkg/mockapi" +) + +// TestAutoscalingSettingsGetDirections checks that each direction of a trigger shows whether the trigger is enabled. +func TestAutoscalingSettingsGetDirections(t *testing.T) { + t.Parallel() + apiHandler := mockapi.NewHandler(t) + projectID := setUpAutoscaling(apiHandler, map[string]any{ + "app": map[string]any{ + "triggers": map[string]any{ + "cpu": map[string]any{ + "enabled": true, + "up": map[string]any{"threshold": 80, "duration": 60}, + "down": map[string]any{"threshold": 20, "duration": 300}, + }, + }, + "instances": map[string]any{"min": 1, "max": 3}, + "scale_cooldown": map[string]any{"up": 300, "down": 600}, + }, + }) + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + f := newCommandFactory(t, apiServer.URL, authServer.URL) + + stdout, stderr, err := f.RunCombinedOutput("autoscaling:get", "-p", projectID, "-e", "main", "--format", "csv", + "--columns", "service,metric,direction,threshold,duration,enabled,cooldown,min_instances,max_instances") + require.NoError(t, err, stderr) + assertTrimmed(t, ` +App or service,Metric,Direction,Threshold (%),Duration (s),Enabled,Cooldown (s),Minimum instances,Maximum instances +app,cpu,up,80.0%,60,true,300,1,3 +app,cpu,down,20.0%,300,true,600,1,3 +`, stdout) +} + +// TestAutoscalingSettingsSetPartial checks changes to settings that omit values, e.g. after a partial update. +func TestAutoscalingSettingsSetPartial(t *testing.T) { + t.Parallel() + apiHandler := mockapi.NewHandler(t) + projectID := setUpAutoscaling(apiHandler, map[string]any{ + "app": map[string]any{ + "triggers": map[string]any{ + "cpu": map[string]any{"enabled": true, "up": map[string]any{"threshold": 80}}, + }, + "instances": map[string]any{"max": 3}, + "scale_cooldown": map[string]any{"down": 600}, + }, + }) + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + f := newCommandFactory(t, apiServer.URL, authServer.URL) + + // Verbose output shows PHP warnings. + _, stderr, err := f.RunCombinedOutput("autoscaling:set", "-v", "-p", projectID, "-e", "main", + "--service", "app", "--metric", "cpu", "--duration-up", "2m", "--cooldown-up", "5m", "--instances-min", "2", + "--dry-run") + require.NoError(t, err, stderr) + assert.NotContains(t, stderr, "Warning") + assert.Contains(t, stderr, "Duration: 2m") + assert.Contains(t, stderr, "Cooldown: 5m") + assert.Contains(t, stderr, "Min: 2") +} diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 11d409fed..0030b52f0 100644 --- a/legacy/phpstan-baseline.neon +++ b/legacy/phpstan-baseline.neon @@ -438,144 +438,24 @@ parameters: count: 1 path: src/Command/Auth/VerifyPhoneNumberCommand.php - - - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - - message: '#^Cannot access offset ''duration'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - - message: '#^Cannot access offset ''max'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - - message: '#^Cannot access offset ''min'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - message: '#^Cannot access offset ''supports_horizontal_scaling_services'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible count: 1 path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - message: '#^Cannot access offset ''threshold'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - - message: '#^Cannot access offset mixed on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - - message: '#^Parameter \#1 \$autoscalingSettings of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsGetCommand\:\:filterAutoscalingSettings\(\) expects array\\>, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - - message: '#^Parameter \#1 \$rows of method Platformsh\\Cli\\Service\\Table\:\:render\(\) expects array\\|Symfony\\Component\\Console\\Helper\\TableSeparator\>, list\\> given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsGetCommand.php - - - - message: '#^Cannot access offset ''down'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Cannot access offset ''duration'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 4 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Cannot access offset ''enabled'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Cannot access offset ''max'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Cannot access offset ''min'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Cannot access offset ''supports_horizontal_scaling_services'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible count: 2 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Cannot access offset ''threshold'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 4 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Cannot access offset ''threshold\-up'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible count: 1 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Cannot access offset ''triggers'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Cannot access offset ''up'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Cannot access offset mixed on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 5 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Cannot access offset string on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 13 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Cannot access offset string\|null on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:askForDurationSetting\(\) should return int but returns mixed\.$#' identifier: return.type @@ -594,22 +474,10 @@ parameters: count: 1 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Parameter \#1 \$defaults of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:getSupportedMetrics\(\) expects array\, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#1 \$previousValue of method Platformsh\\Cli\\Service\\ResourcesUtil\:\:formatChange\(\) expects float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 4 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:formatDuration\(\) expects int, mixed given\.$#' identifier: argument.type - count: 8 + count: 4 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - @@ -636,72 +504,12 @@ parameters: count: 1 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Parameter \#2 \$currentServiceSettings of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:handleInstanceSettings\(\) expects array\\|null, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#2 \$limit of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:validateInstanceCount\(\) expects int\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Parameter \#2 \$newValue of method Platformsh\\Cli\\Service\\ResourcesUtil\:\:formatChange\(\) expects float\|int\|string\|null, mixed given\.$#' identifier: argument.type count: 4 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Parameter \#2 \$settings of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:summarizeChanges\(\) expects array\\>, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#3 \$existingValue of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:askForDurationSetting\(\) expects int\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#3 \$instanceLimit of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:handleInstanceSettings\(\) expects int, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#4 \$currentServiceSettings of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:handleScalingSettings\(\) expects array\\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#4 \$defaultValue of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:askForDurationSetting\(\) expects int, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#5 \$defaults of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:handleScalingSettings\(\) expects array\, array\ given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Parameter &\$instancesMax by\-ref type of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:handleInstanceSettings\(\) expects int\|null, mixed given\.$#' identifier: parameterByRef.type @@ -726,12 +534,6 @@ parameters: count: 1 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Possibly invalid array key type mixed\.$#' - identifier: offsetAccess.invalidOffset - count: 4 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type @@ -2613,17 +2415,11 @@ parameters: - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable - count: 2 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Cannot access offset ''disk'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible count: 1 path: src/Command/Resources/ResourcesGetCommand.php - - message: '#^Cannot access offset ''enabled'' on mixed\.$#' + message: '#^Cannot access offset ''disk'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible count: 1 path: src/Command/Resources/ResourcesGetCommand.php @@ -2661,13 +2457,13 @@ parameters: - message: '#^Possibly invalid array key type mixed\.$#' identifier: offsetAccess.invalidOffset - count: 6 + count: 5 path: src/Command/Resources/ResourcesGetCommand.php - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable - count: 4 + count: 3 path: src/Command/Resources/ResourcesSetCommand.php - @@ -2718,12 +2514,6 @@ parameters: count: 8 path: src/Command/Resources/ResourcesSetCommand.php - - - message: '#^Cannot access offset ''enabled'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Cannot access offset ''instance_limit'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -2979,7 +2769,7 @@ parameters: - message: '#^Possibly invalid array key type mixed\.$#' identifier: offsetAccess.invalidOffset - count: 9 + count: 8 path: src/Command/Resources/ResourcesSetCommand.php - diff --git a/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php b/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php index 3bde3d6ee..fc4b8b173 100644 --- a/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php +++ b/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php @@ -9,6 +9,7 @@ use Platformsh\Cli\Service\ResourcesUtil; use Platformsh\Cli\Selector\Selector; use Platformsh\Cli\Command\CommandBase; +use Platformsh\Cli\Model\AutoscalingSettings; use Platformsh\Cli\Service\Table; use Platformsh\Client\Exception\EnvironmentStateException; use Platformsh\Client\Model\Deployment\Service; @@ -17,6 +18,9 @@ use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Output\OutputInterface; +/** + * @phpstan-import-type ServiceSettings from AutoscalingSettings + */ #[AsCommand(name: 'autoscaling:get', description: 'View the autoscaling configuration of apps, workers, and services on an environment', aliases: ['autoscaling'])] class AutoscalingSettingsGetCommand extends CommandBase { @@ -74,7 +78,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->stdErr->writeln(\sprintf('Autoscaling support is not currently available on the environment: %s', $this->api->getEnvironmentLabel($environment, 'error'))); return 1; } - $autoscalingSettings = $autoscalingSettings->getData(); $services = $this->resourcesUtil->allServices($deployment); // Autoscaling does not apply to tasks. @@ -84,9 +87,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } - if (!empty($autoscalingSettings['services'])) { + if (!empty($autoscalingSettings->services)) { // Filter autoscaling settings to only show services that are allowed to be configured. - $filteredSettings = $this->filterAutoscalingSettings($autoscalingSettings['services'], $services, $selection->getProject()); + $filteredSettings = $this->filterAutoscalingSettings($autoscalingSettings->services, $services, $selection->getProject()); if (empty($filteredSettings)) { $this->stdErr->writeln(sprintf('No autoscaling configuration found for the project %s, environment %s.', $this->api->getProjectLabel($selection->getProject()), $this->api->getEnvironmentLabel($environment))); @@ -119,13 +122,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'instance_count' => $empty, ]; - foreach ($settings['triggers'] as $metric => $conditions) { + foreach ($settings['triggers'] as $metric => $trigger) { $row['metric'] = $metric; - foreach ($conditions as $direction => $condition) { - if ($direction === 'enabled') { - $row['enabled'] = $this->propertyFormatter->format($condition, 'enabled'); + if (isset($trigger['enabled'])) { + $row['enabled'] = $this->propertyFormatter->format($trigger['enabled'], 'enabled'); + } + foreach (['up', 'down'] as $direction) { + if (!isset($trigger[$direction])) { continue; } + $condition = $trigger[$direction]; $row['direction'] = $direction; $row['threshold'] = isset($condition['threshold']) ? sprintf('%.1f%%', $condition['threshold']) : $empty; $row['duration'] = $condition['duration'] ?? $empty; @@ -160,11 +166,11 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * Filters autoscaling settings to only include services that are allowed to be configured. * - * @param array> $autoscalingSettings Autoscaling settings from the API + * @param array $autoscalingSettings Autoscaling settings from the API * @param array $services Array of Service|WebApp|Worker objects from deployment * @param \Platformsh\Client\Model\Project $project * - * @return array> Filtered autoscaling settings + * @return array Filtered autoscaling settings */ protected function filterAutoscalingSettings(array $autoscalingSettings, array $services, \Platformsh\Client\Model\Project $project): array { diff --git a/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php b/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php index ad1d3f937..8c3539aed 100644 --- a/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php +++ b/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php @@ -12,6 +12,7 @@ use Platformsh\Cli\Selector\Selector; use Platformsh\Cli\Service\SubCommandRunner; use Platformsh\Cli\Command\CommandBase; +use Platformsh\Cli\Model\AutoscalingSettings; use Platformsh\Client\Exception\EnvironmentStateException; use Platformsh\Client\Model\Deployment\Service; use Platformsh\Client\Model\Deployment\Task; @@ -22,6 +23,9 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +/** + * @phpstan-import-type ServiceSettings from AutoscalingSettings + */ #[AsCommand(name: 'autoscaling:set', description: 'Set the autoscaling configuration of apps, workers, or services in an environment')] class AutoscalingSettingsSetCommand extends CommandBase { @@ -93,7 +97,6 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$autoscalingSettings) { throw new EnvironmentStateException('Managing autoscaling settings is not currently available', $environment); } - $autoscalingSettings = $autoscalingSettings->getData(); $services = $this->resourcesUtil->allServices($deployment); // Autoscaling does not apply to tasks. @@ -104,8 +107,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Get autoscaling default values - $defaults = $autoscalingSettings['defaults'] ?? null; - if (!is_array($defaults)) { + $defaults = $autoscalingSettings->defaults; + if ($defaults === null) { $this->stdErr->writeln('The autoscaling API did not return any default settings for this environment.'); return 1; } @@ -160,7 +163,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Validate the --instances-* options. - $instanceLimit = $defaults['instances']['max']; + $instanceLimit = $defaults['instances']['max'] ?? null; $instancesMin = Option::stringOrNull($input, 'instances-min'); if ($instancesMin !== null) { $instancesMin = $this->validateInstanceCount($instancesMin, $instanceLimit, 'instances-min'); @@ -214,7 +217,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $service = $this->questionHelper->choose($serviceNamesIndexed, $text, $default); // Get autoscaling current values for selected service - $currentServiceSettings = $autoscalingSettings['services'][$service] ?? null; + $currentServiceSettings = $autoscalingSettings->services[$service] ?? null; $this->stdErr->writeln('' . ucfirst($this->typeName($services[$service])) . ': ' . $service . ''); $this->stdErr->writeln(''); @@ -286,7 +289,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Only mark 'enabled' as an updated field if it was explicitly set and is changing - $currentServiceSettings = $autoscalingSettings['services'][$service] ?? null; + $currentServiceSettings = $autoscalingSettings->services[$service] ?? null; if ($enabled !== null && ($currentServiceSettings['triggers'][$metric]['enabled'] ?? false) !== $enabled) { $updates[$service]['enabled'] = $enabled; } @@ -308,7 +311,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - $this->summarizeChanges($updates, $autoscalingSettings['services']); + $this->summarizeChanges($updates, $autoscalingSettings->services); $this->io->debug('Raw updates: ' . json_encode($updates, JSON_UNESCAPED_SLASHES)); @@ -335,8 +338,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * Handle scaling settings (up/down) for interactive mode. * - * @param array|null $currentServiceSettings - * @param array $defaults + * @param 'up'|'down' $direction + * @param ServiceSettings|null $currentServiceSettings + * @param ServiceSettings $defaults * @param array> $updates */ private function handleScalingSettings( @@ -359,7 +363,7 @@ private function handleScalingSettings( $threshold, 'Enter the threshold (%)', $currentServiceSettings['triggers'][$metric][$direction]['threshold'] ?? null, - $defaults['triggers'][$metric][$direction]['threshold'], + $defaults['triggers'][$metric][$direction]['threshold'] ?? null, function ($value) { return $this->validateThreshold($value); }, @@ -372,7 +376,7 @@ function ($value) { $duration, 'Enter the duration of the evaluation period', $currentServiceSettings['triggers'][$metric][$direction]['duration'] ?? null, - $defaults['triggers'][$metric][$direction]['duration'], + $defaults['triggers'][$metric][$direction]['duration'] ?? null, $service, 'duration-' . $direction, $updates @@ -382,7 +386,7 @@ function ($value) { $cooldown, 'Enter the duration of the cool-down period', $currentServiceSettings['scale_cooldown'][$direction] ?? null, - $defaults['scale_cooldown'][$direction], + $defaults['scale_cooldown'][$direction] ?? null, $service, 'cooldown-' . $direction, $updates @@ -393,13 +397,13 @@ function ($value) { /** * Handle instance settings for interactive mode. * - * @param array|null $currentServiceSettings + * @param ServiceSettings|null $currentServiceSettings * @param array> $updates */ private function handleInstanceSettings( string $service, ?array $currentServiceSettings, - int $instanceLimit, + ?int $instanceLimit, ?int &$instancesMin, ?int &$instancesMax, array &$updates @@ -479,7 +483,7 @@ private function askForSetting( * @param int|null $currentValue Current duration value (null if not set) * @param string $prompt Prompt text for user input * @param int|null $existingValue Existing duration from current settings - * @param int $defaultValue Default duration value + * @param int|null $defaultValue Default duration value * @param string $service Service name * @param string $updateKey Key to use in updates array * @param array> $updates Updates array (passed by reference) @@ -490,7 +494,7 @@ private function askForDurationSetting( ?int $currentValue, string $prompt, ?int $existingValue, - int $defaultValue, + ?int $defaultValue, string $service, string $updateKey, array &$updates @@ -498,7 +502,7 @@ private function askForDurationSetting( if ($currentValue === null) { $choices = array_keys(self::$validDurations); $default = $existingValue ?? $defaultValue; - $newValue = $this->questionHelper->askInput($prompt, $this->formatDuration($default), $choices, function ($v) { + $newValue = $this->questionHelper->askInput($prompt, $default !== null ? $this->formatDuration($default) : null, $choices, function ($v) { return $this->validateDuration($v); }); $this->stdErr->writeln(''); @@ -585,7 +589,7 @@ protected function makeAutoscalingSettingsData(array $updates): array * Summarizes all the changes that would be made. * * @param array> $updates - * @param array> $settings + * @param array $settings */ private function summarizeChanges(array $updates, array $settings): void { @@ -598,14 +602,17 @@ private function summarizeChanges(array $updates, array $settings): void /** * Summarizes changes per service. * - * @param array|null $current + * @param ServiceSettings|null $current * @param array $updates */ private function summarizeChangesPerService(string $name, ?array $current, array $updates): void { $this->stdErr->writeln(sprintf(' Service: %s', $name)); - $metric = $updates['metric']; + $metric = $updates['metric'] ?? null; + if (!is_string($metric)) { + throw new \LogicException('No metric is set for the service: ' . $name); + } $this->stdErr->writeln(sprintf(' Metric: %s', $metric)); $action = 'remain'; @@ -626,20 +633,20 @@ private function summarizeChangesPerService(string $name, ?array $current, array if (isset($updates['threshold-up'])) { $this->stdErr->writeln(' Threshold: ' . $this->resourcesUtil->formatChange( - isset($current['triggers'][$metric]['up']) ? $current['triggers'][$metric]['up']['threshold'] : null, + $current['triggers'][$metric]['up']['threshold'] ?? null, $updates['threshold-up'], '%' )); } if (isset($updates['duration-up'])) { $this->stdErr->writeln(' Duration: ' . $this->formatDurationChange( - isset($current['triggers'][$metric]['up']) ? $this->formatDuration($current['triggers'][$metric]['up']['duration']) : null, + isset($current['triggers'][$metric]['up']['duration']) ? $this->formatDuration($current['triggers'][$metric]['up']['duration']) : null, $this->formatDuration($updates['duration-up']) )); } if (isset($updates['cooldown-up'])) { $this->stdErr->writeln(' Cooldown: ' . $this->formatDurationChange( - isset($current['scale_cooldown']) ? $this->formatDuration($current['scale_cooldown']['up']) : null, + isset($current['scale_cooldown']['up']) ? $this->formatDuration($current['scale_cooldown']['up']) : null, $this->formatDuration($updates['cooldown-up']) )); } @@ -650,20 +657,20 @@ private function summarizeChangesPerService(string $name, ?array $current, array if (isset($updates['threshold-down'])) { $this->stdErr->writeln(' Threshold: ' . $this->resourcesUtil->formatChange( - isset($current['triggers'][$metric]['down']) ? $current['triggers'][$metric]['down']['threshold'] : null, + $current['triggers'][$metric]['down']['threshold'] ?? null, $updates['threshold-down'], '%' )); } if (isset($updates['duration-down'])) { $this->stdErr->writeln(' Duration: ' . $this->formatDurationChange( - isset($current['triggers'][$metric]['down']) ? $this->formatDuration($current['triggers'][$metric]['down']['duration']) : null, + isset($current['triggers'][$metric]['down']['duration']) ? $this->formatDuration($current['triggers'][$metric]['down']['duration']) : null, $this->formatDuration($updates['duration-down']) )); } if (isset($updates['cooldown-down'])) { $this->stdErr->writeln(' Cooldown: ' . $this->formatDurationChange( - isset($current['scale_cooldown']) ? $this->formatDuration($current['scale_cooldown']['down']) : null, + isset($current['scale_cooldown']['down']) ? $this->formatDuration($current['scale_cooldown']['down']) : null, $this->formatDuration($updates['cooldown-down']) )); } @@ -673,14 +680,14 @@ private function summarizeChangesPerService(string $name, ?array $current, array $this->stdErr->writeln(' Instances'); if (isset($updates['instances-min'])) { $this->stdErr->writeln(' Min: ' . $this->resourcesUtil->formatChange( - isset($current['instances']) ? $current['instances']['min'] : null, + $current['instances']['min'] ?? null, $updates['instances-min'] )); } if (isset($updates['instances-max'])) { $this->stdErr->writeln(' Max: ' . $this->resourcesUtil->formatChange( - isset($current['instances']) ? $current['instances']['max'] : null, + $current['instances']['max'] ?? null, $updates['instances-max'] )); } @@ -786,7 +793,7 @@ protected function filterServicesWithAutoscalingSupport(array $services, \Platfo /** * Return the names of supported metrics. * - * @param array $defaults Autoscaling settings defaults + * @param ServiceSettings $defaults Autoscaling settings defaults * * @return array Supported metric names */ @@ -979,7 +986,7 @@ protected function formatDurationChange(int|string|null $previousValue, int|stri $previousValue, $newValue, '', - function ($previousValue, $newValue) { + function (string $previousValue, string $newValue): bool { if (!isset(self::$validDurations[$previousValue]) || !isset(self::$validDurations[$newValue])) { throw new InvalidArgumentException(sprintf( 'Invalid duration key(s): previousValue=%s, newValue=%s. Valid keys are: %s', diff --git a/legacy/src/Command/Resources/ResourcesGetCommand.php b/legacy/src/Command/Resources/ResourcesGetCommand.php index cf825206b..126a86b92 100644 --- a/legacy/src/Command/Resources/ResourcesGetCommand.php +++ b/legacy/src/Command/Resources/ResourcesGetCommand.php @@ -100,7 +100,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Check autoscaling settings for the environment, as autoscaling prevents changing some resources manually. $autoscalingSettings = $this->api->getAutoscalingSettings($environment); if ($autoscalingSettings) { - foreach ($autoscalingSettings->getData()['services'] as $service => $serviceSettings) { + foreach ($autoscalingSettings->services as $service => $serviceSettings) { $autoscalingEnabled[$service] = !empty($serviceSettings['enabled']); } } diff --git a/legacy/src/Command/Resources/ResourcesSetCommand.php b/legacy/src/Command/Resources/ResourcesSetCommand.php index ec502f81d..071303163 100644 --- a/legacy/src/Command/Resources/ResourcesSetCommand.php +++ b/legacy/src/Command/Resources/ResourcesSetCommand.php @@ -152,7 +152,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Check autoscaling settings for the environment, as autoscaling prevents changing some resources manually. $autoscalingSettings = $this->api->getAutoscalingSettings($environment); if ($autoscalingSettings) { - foreach ($autoscalingSettings->getData()['services'] as $service => $serviceSettings) { + foreach ($autoscalingSettings->services as $service => $serviceSettings) { $autoscalingEnabled[$service] = !empty($serviceSettings['enabled']); } } diff --git a/legacy/src/Model/AutoscalingSettings.php b/legacy/src/Model/AutoscalingSettings.php new file mode 100644 index 000000000..d6adc2fae --- /dev/null +++ b/legacy/src/Model/AutoscalingSettings.php @@ -0,0 +1,119 @@ +, + * instances: array{min?: int, max?: int}, + * scale_cooldown: array{up?: int, down?: int}, + * } + */ +final class AutoscalingSettings +{ + /** + * @param ServiceSettings|null $defaults + * @param array $services + */ + private function __construct(public readonly ?array $defaults, public readonly array $services) {} + + /** + * @param array $data + */ + public static function fromData(array $data): self + { + $services = []; + if (isset($data['services']) && is_array($data['services'])) { + foreach ($data['services'] as $name => $settings) { + $services[(string) $name] = self::serviceSettings($settings); + } + } + $defaults = isset($data['defaults']) && is_array($data['defaults']) ? self::serviceSettings($data['defaults']) : null; + + return new self($defaults, $services); + } + + /** + * @return ServiceSettings + */ + private static function serviceSettings(mixed $data): array + { + $data = is_array($data) ? $data : []; + $settings = [ + 'triggers' => [], + 'instances' => [], + 'scale_cooldown' => [], + ]; + if (isset($data['enabled']) && is_bool($data['enabled'])) { + $settings['enabled'] = $data['enabled']; + } + if (isset($data['triggers']) && is_array($data['triggers'])) { + foreach ($data['triggers'] as $metric => $trigger) { + $settings['triggers'][(string) $metric] = self::trigger($trigger); + } + } + $instances = isset($data['instances']) && is_array($data['instances']) ? $data['instances'] : []; + if (isset($instances['min']) && is_int($instances['min'])) { + $settings['instances']['min'] = $instances['min']; + } + if (isset($instances['max']) && is_int($instances['max'])) { + $settings['instances']['max'] = $instances['max']; + } + $cooldown = isset($data['scale_cooldown']) && is_array($data['scale_cooldown']) ? $data['scale_cooldown'] : []; + if (isset($cooldown['up']) && is_int($cooldown['up'])) { + $settings['scale_cooldown']['up'] = $cooldown['up']; + } + if (isset($cooldown['down']) && is_int($cooldown['down'])) { + $settings['scale_cooldown']['down'] = $cooldown['down']; + } + + return $settings; + } + + /** + * @return Trigger + */ + private static function trigger(mixed $data): array + { + $data = is_array($data) ? $data : []; + $trigger = []; + if (isset($data['enabled']) && is_bool($data['enabled'])) { + $trigger['enabled'] = $data['enabled']; + } + if (isset($data['up']) && is_array($data['up'])) { + $trigger['up'] = self::condition($data['up']); + } + if (isset($data['down']) && is_array($data['down'])) { + $trigger['down'] = self::condition($data['down']); + } + + return $trigger; + } + + /** + * @param array $data + * + * @return Condition + */ + private static function condition(array $data): array + { + $condition = []; + if (isset($data['threshold']) && (is_int($data['threshold']) || is_float($data['threshold']))) { + $condition['threshold'] = (float) $data['threshold']; + } + if (isset($data['duration']) && is_int($data['duration'])) { + $condition['duration'] = $data['duration']; + } + + return $condition; + } +} diff --git a/legacy/src/Service/Api.php b/legacy/src/Service/Api.php index 27accbbc7..d2f1e98bb 100644 --- a/legacy/src/Service/Api.php +++ b/legacy/src/Service/Api.php @@ -31,6 +31,7 @@ use Platformsh\Cli\Exception\GoLoginRequiredException; use Platformsh\Cli\Exception\ProcessFailedException; use Platformsh\Cli\GuzzleDebugMiddleware; +use Platformsh\Cli\Model\AutoscalingSettings; use Platformsh\Cli\Model\Route; use Platformsh\Cli\Util\Jwt; use Platformsh\Cli\Util\NamespacedCache; @@ -39,7 +40,6 @@ use Platformsh\Cli\Connector; use Platformsh\Client\Exception\ApiResponseException; use Platformsh\Client\Exception\EnvironmentStateException; -use Platformsh\Client\Model\AutoscalingSettings; use Platformsh\Client\Model\BasicProjectInfo; use Platformsh\Client\Model\Deployment\EnvironmentDeployment; use Platformsh\Client\Model\Environment; @@ -2174,10 +2174,10 @@ public function getAutoscalingSettingsLink(Environment $environment, bool $manag * * @param Environment $environment * - * @return \Platformsh\Client\Model\AutoscalingSettings|false + * @return AutoscalingSettings|false * The autoscaling settings for the environment or false on failure. */ - public function getAutoscalingSettings(Environment $environment) + public function getAutoscalingSettings(Environment $environment): AutoscalingSettings|false { $autoscalingSettingsLink = $this->getAutoscalingSettingsLink($environment); if (!$autoscalingSettingsLink) { @@ -2192,7 +2192,7 @@ public function getAutoscalingSettings(Environment $environment) } return false; } - return new AutoscalingSettings($result->getData(), $autoscalingSettingsLink); + return AutoscalingSettings::fromData($result->getData()); } /** From 50f9d4a22b518955cee98efaaac0c83f5be78726 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 26 Oct 2026 07:59:29 +0000 Subject: [PATCH 2/5] refactor(resources): type the resources data where it is read ResourcesUtil now reads a container's resources, profile, instance count and disk, and a deployment's container profiles, as typed values. Values that are missing or of an unexpected type are left out. The resources commands use these instead of indexing the raw properties. - resources:build:set compared the CPU option, a float, with the API's integer, so setting the current CPU sent an unchanged value. It also failed every value when the project's capabilities had no build limits. - resources:size:list handles a task's default profile and an unknown one. - resources:get no longer copies raw resources values into its table cells. - QuestionHelper::askInput() returns its validator's type. - ResourcesCommandBase's unused copies of ResourcesUtil methods are removed. Co-Authored-By: Claude Opus 5.5 --- integration-tests/resources_build_test.go | 9 +- legacy/phpstan-baseline.neon | 706 ++---------------- .../Build/BuildResourcesGetCommand.php | 6 +- .../Build/BuildResourcesSetCommand.php | 29 +- .../Resources/ResourcesCommandBase.php | 178 +---- .../Command/Resources/ResourcesGetCommand.php | 38 +- .../Command/Resources/ResourcesSetCommand.php | 147 ++-- .../Resources/ResourcesSizeListCommand.php | 15 +- legacy/src/Service/QuestionHelper.php | 10 +- legacy/src/Service/ResourcesUtil.php | 157 +++- .../Command/Resources/ResourcesSetTest.php | 2 +- 11 files changed, 337 insertions(+), 960 deletions(-) diff --git a/integration-tests/resources_build_test.go b/integration-tests/resources_build_test.go index 1295df536..46dd38fd9 100644 --- a/integration-tests/resources_build_test.go +++ b/integration-tests/resources_build_test.go @@ -108,9 +108,12 @@ func TestBuildResourcesSet(t *testing.T) { assert.Contains(t, stderr, c.wantErr) } - _, stderr, err := f.RunCombinedOutput("resources:build:set", "-p", projectID, "--memory", "2048") - require.NoError(t, err, stderr) - assert.Contains(t, stderr, "No changes were provided: nothing to update.") + // The current values are the API's integers. + for _, args := range [][]string{{"--memory", "2048"}, {"--cpu", "1"}} { + _, stderr, err := f.RunCombinedOutput(append([]string{"resources:build:set", "-p", projectID}, args...)...) + require.NoError(t, err, stderr) + assert.Contains(t, stderr, "No changes were provided: nothing to update.") + } assert.Empty(t, apiHandler.ProjectSettingsPatches(projectID)) }) diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 0030b52f0..49b291596 100644 --- a/legacy/phpstan-baseline.neon +++ b/legacy/phpstan-baseline.neon @@ -414,24 +414,6 @@ parameters: count: 1 path: src/Command/Auth/BrowserLoginCommand.php - - - message: '#^Binary operation "\." between ''A verification code…'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 2 - path: src/Command/Auth/VerifyPhoneNumberCommand.php - - - - message: '#^Binary operation "\." between ''Calling the number …'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Command/Auth/VerifyPhoneNumberCommand.php - - - - message: '#^Binary operation "\." between ''E164\-formatted…'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Command/Auth/VerifyPhoneNumberCommand.php - - message: '#^Cannot cast mixed to string\.$#' identifier: cast.string @@ -456,12 +438,6 @@ parameters: count: 1 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:askForDurationSetting\(\) should return int but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:handleScalingSettings\(\) never assigns null to &\$cooldown so it can be removed from the by\-ref type\.$#' identifier: parameterByRef.unusedType @@ -480,12 +456,6 @@ parameters: count: 4 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:validateDuration\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:validateInstanceCount\(\) expects string, mixed given\.$#' identifier: argument.type @@ -1890,12 +1860,6 @@ parameters: count: 1 path: src/Command/Organization/Billing/OrganizationProfileCommand.php - - - message: '#^Possibly invalid array key type mixed\.$#' - identifier: offsetAccess.invalidOffset - count: 1 - path: src/Command/Organization/OrganizationCommandBase.php - - message: '#^Cannot access offset ''label'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -2040,48 +2004,12 @@ parameters: count: 1 path: src/Command/Organization/OrganizationSubscriptionListCommand.php - - - message: '#^Parameter \#1 \$args of static method Platformsh\\Cli\\Console\\ArrayArgument\:\:split\(\) expects array\, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Organization/User/OrganizationUserAddCommand.php - - - - message: '#^Parameter \#1 \$argument of static method Platformsh\\Cli\\Util\\OsUtil\:\:escapeShellArg\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Organization/User/OrganizationUserAddCommand.php - - - - message: '#^Parameter \#1 \$email of method Platformsh\\Client\\Model\\Organization\\Organization\:\:inviteMemberByEmail\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Organization/User/OrganizationUserAddCommand.php - - message: '#^Parameter \#1 \$permissions of method Platformsh\\Cli\\Command\\Organization\\User\\OrganizationUserCommandBase\:\:listPermissions\(\) expects array\\|null, mixed given\.$#' identifier: argument.type count: 1 path: src/Command/Organization/User/OrganizationUserAddCommand.php - - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Organization\\User\\OrganizationUserAddCommand\:\:validateEmail\(\) expects string\|null, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Organization/User/OrganizationUserAddCommand.php - - - - message: '#^Parameter \#2 \$email of method Platformsh\\Cli\\Service\\Api\:\:loadMemberByEmail\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Organization/User/OrganizationUserAddCommand.php - - - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Organization/User/OrganizationUserAddCommand.php - - message: '#^Cannot access offset string on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -2290,474 +2218,144 @@ parameters: message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type count: 2 - path: src/Command/Repo/RepoCommandBase.php - - - - message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Repo/RepoCommandBase.php - - - - message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Repo/RepoCommandBase.php - - - - message: '#^Parameter \#5 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Repo/RepoCommandBase.php - - - - message: '#^Cannot access offset ''cpu'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/Build/BuildResourcesGetCommand.php - - - - message: '#^Cannot access offset ''memory'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/Build/BuildResourcesGetCommand.php - - - - message: '#^Parameter \#1 \$rows of method Platformsh\\Cli\\Service\\Table\:\:render\(\) expects array\\|Symfony\\Component\\Console\\Helper\\TableSeparator\>, array\\> given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/Build/BuildResourcesGetCommand.php - - - - message: '#^Cannot access offset ''cpu'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Cannot access offset ''max_cpu'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Cannot access offset ''max_memory'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Cannot access offset ''memory'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Cannot cast mixed to float\.$#' - identifier: cast.double - count: 1 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Cannot cast mixed to int\.$#' - identifier: cast.int - count: 1 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Parameter \#1 \$unformatted of method Platformsh\\Cli\\Service\\ResourcesUtil\:\:formatCPU\(\) expects float\|int\|string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Parameter \#1 \$updates of method Platformsh\\Cli\\Command\\Resources\\Build\\BuildResourcesSetCommand\:\:summarizeChanges\(\) expects array\{cpu\?\: int, memory\?\: int\}, non\-empty\-array\{cpu\?\: mixed, memory\?\: mixed\} given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Parameter \#2 \$current of method Platformsh\\Cli\\Command\\Resources\\Build\\BuildResourcesSetCommand\:\:summarizeChanges\(\) expects array\{cpu\: int, memory\: int\}, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - - - message: '#^Cannot access offset ''disk'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesCommandBase.php - - - - message: '#^Cannot access offset ''minimum'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesCommandBase.php - - - - message: '#^Cannot access offset ''profile_size'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesCommandBase.php - - - - message: '#^Possibly invalid array key type mixed\.$#' - identifier: offsetAccess.invalidOffset - count: 4 - path: src/Command/Resources/ResourcesCommandBase.php - - - - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 1 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Cannot access offset ''disk'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Cannot access offset ''object'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Cannot access offset ''profile_size'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 1 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Parameter \#1 \$profiles of method Platformsh\\Cli\\Command\\Resources\\ResourcesCommandBase\:\:sortContainerProfiles\(\) expects array\\>\>, array given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Parameter \#1 \$rows of method Platformsh\\Cli\\Service\\Table\:\:render\(\) expects array\\|Symfony\\Component\\Console\\Helper\\TableSeparator\>, list\\> given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Possibly invalid array key type mixed\.$#' - identifier: offsetAccess.invalidOffset - count: 5 - path: src/Command/Resources/ResourcesGetCommand.php - - - - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' - identifier: foreach.nonIterable - count: 3 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Binary operation "\*" between mixed and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 4 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Binary operation "\-" between mixed and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Binary operation "\." between ''Failed to load…'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''capabilities'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''cpu'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 7 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''cpu_type'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''default'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 4 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''disk'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 8 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''instance_limit'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''memory'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 7 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''minimum'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 8 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''object'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''profile_size'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 15 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''resource_limit'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset ''status'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot access offset mixed on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 4 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot cast mixed to int\.$#' - identifier: cast.int - count: 2 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 4 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:trialResourceLimits\(\) should return array\{array\\|null, array\\|null\} but returns array\{mixed, mixed\}\.$#' - identifier: return.type - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateProfileSize\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 2 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Parameter \#1 \$array of function array_keys expects array, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Parameter \#1 \$id of method Platformsh\\Cli\\Service\\Api\:\:getOrganizationById\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Parameter \#1 \$previousValue of method Platformsh\\Cli\\Service\\ResourcesUtil\:\:formatChange\(\) expects float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 3 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Parameter \#1 \$properties of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:diskIsRequiredButUnset\(\) expects array\, array given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Parameter \#1 \$properties of method Platformsh\\Cli\\Service\\ResourcesUtil\:\:sizeInfo\(\) expects array\, array given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Resources/ResourcesSetCommand.php + path: src/Command/Repo/RepoCommandBase.php - - message: '#^Parameter \#1 \$updates of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:computeMemoryCPUStorageDiff\(\) expects array\\>\>, array\\>\> given\.$#' + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type count: 1 - path: src/Command/Resources/ResourcesSetCommand.php + path: src/Command/Repo/RepoCommandBase.php - - message: '#^Parameter \#1 \$updates of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:summarizeChanges\(\) expects array\\>\>, array\\>\> given\.$#' + message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type count: 1 - path: src/Command/Resources/ResourcesSetCommand.php + path: src/Command/Repo/RepoCommandBase.php - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateDiskSize\(\) expects string, mixed given\.$#' + message: '#^Parameter \#5 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type - count: 2 - path: src/Command/Resources/ResourcesSetCommand.php + count: 1 + path: src/Command/Repo/RepoCommandBase.php - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateInstanceCount\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Resources/ResourcesSetCommand.php + message: '#^Cannot access offset ''cpu'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Command/Resources/Build/BuildResourcesSetCommand.php - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateObjectStorage\(\) expects string, mixed given\.$#' - identifier: argument.type + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable count: 1 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateProfileSize\(\) expects string, mixed given\.$#' - identifier: argument.type + message: '#^Binary operation "\." between ''Failed to load…'' and mixed results in an error\.$#' + identifier: binaryOp.invalid count: 1 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#2 \$current of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:computeMemoryCPUStorageDiff\(\) expects array\\>\>, array\\>\> given\.$#' - identifier: argument.type - count: 1 + message: '#^Cannot access offset ''cpu'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#2 \$newValue of method Platformsh\\Cli\\Service\\ResourcesUtil\:\:formatChange\(\) expects float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 3 + message: '#^Cannot access offset ''default'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#2 \$serviceName of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateDiskSize\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 + message: '#^Cannot access offset ''disk'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#2 \$serviceName of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateInstanceCount\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 + message: '#^Cannot access offset ''memory'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#2 \$serviceName of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateObjectStorage\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 + message: '#^Cannot access offset ''minimum'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 5 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#2 \$serviceName of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateProfileSize\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 + message: '#^Cannot access offset ''profile_size'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#2 \$services of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:summarizeChanges\(\) expects array\, array\ given\.$#' - identifier: argument.type + message: '#^Cannot access offset ''resource_limit'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible count: 1 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 7 + message: '#^Cannot access offset ''status'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#3 \$containerProfiles of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:summarizeChanges\(\) expects array\, array given\.$#' - identifier: argument.type - count: 1 + message: '#^Cannot cast mixed to int\.$#' + identifier: cast.int + count: 2 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#3 \$default of method Platformsh\\Cli\\Service\\QuestionHelper\:\:chooseAssoc\(\) expects string\|null, mixed given\.$#' - identifier: argument.type + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string count: 1 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#3 \$service of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateDiskSize\(\) expects Platformsh\\Client\\Model\\Deployment\\Service\|Platformsh\\Client\\Model\\Deployment\\Task\|Platformsh\\Client\\Model\\Deployment\\WebApp\|Platformsh\\Client\\Model\\Deployment\\Worker, mixed given\.$#' - identifier: argument.type + message: '#^Method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:trialResourceLimits\(\) should return array\{array\\|null, array\\|null\} but returns array\{mixed, mixed\}\.$#' + identifier: return.type count: 1 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#3 \$service of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateInstanceCount\(\) expects Platformsh\\Client\\Model\\Deployment\\Service\|Platformsh\\Client\\Model\\Deployment\\Task\|Platformsh\\Client\\Model\\Deployment\\WebApp\|Platformsh\\Client\\Model\\Deployment\\Worker, mixed given\.$#' - identifier: argument.type - count: 1 + message: '#^Method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateProfileSize\(\) should return string but returns mixed\.$#' + identifier: return.type + count: 2 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#3 \$service of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateObjectStorage\(\) expects Platformsh\\Client\\Model\\Deployment\\Service\|Platformsh\\Client\\Model\\Deployment\\Task\|Platformsh\\Client\\Model\\Deployment\\WebApp\|Platformsh\\Client\\Model\\Deployment\\Worker, mixed given\.$#' + message: '#^Parameter \#1 \$array of function array_keys expects array, mixed given\.$#' identifier: argument.type count: 1 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#3 \$service of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateProfileSize\(\) expects Platformsh\\Client\\Model\\Deployment\\Service\|Platformsh\\Client\\Model\\Deployment\\Task\|Platformsh\\Client\\Model\\Deployment\\WebApp\|Platformsh\\Client\\Model\\Deployment\\Worker, mixed given\.$#' + message: '#^Parameter \#1 \$id of method Platformsh\\Cli\\Service\\Api\:\:getOrganizationById\(\) expects string, mixed given\.$#' identifier: argument.type count: 1 path: src/Command/Resources/ResourcesSetCommand.php - - message: '#^Parameter \#3 \$services of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:parseSetting\(\) expects array\, array\ given\.$#' + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type - count: 4 + count: 3 path: src/Command/Resources/ResourcesSetCommand.php - message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type - count: 5 - path: src/Command/Resources/ResourcesSetCommand.php - - - - message: '#^Parameter \#4 \$limit of method Platformsh\\Cli\\Command\\Resources\\ResourcesSetCommand\:\:validateInstanceCount\(\) expects int\|null, mixed given\.$#' - identifier: argument.type - count: 2 + count: 3 path: src/Command/Resources/ResourcesSetCommand.php - message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type - count: 2 + count: 1 path: src/Command/Resources/ResourcesSetCommand.php - @@ -2769,15 +2367,9 @@ parameters: - message: '#^Possibly invalid array key type mixed\.$#' identifier: offsetAccess.invalidOffset - count: 8 + count: 2 path: src/Command/Resources/ResourcesSetCommand.php - - - message: '#^Parameter \#1 \$profiles of method Platformsh\\Cli\\Command\\Resources\\ResourcesCommandBase\:\:sortContainerProfiles\(\) expects array\\>\>, array given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Resources/ResourcesSizeListCommand.php - - message: '#^Parameter \#1 \$routes of static method Platformsh\\Cli\\Model\\Route\:\:fromVariables\(\) expects array\, mixed given\.$#' identifier: argument.type @@ -3072,54 +2664,6 @@ parameters: count: 1 path: src/Command/Service/ServiceListCommand.php - - - message: '#^Parameter \#1 \$id of method Platformsh\\Cli\\Service\\Config\:\:setSessionId\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Session/SessionSwitchCommand.php - - - - message: '#^Parameter \#1 \$id of method Platformsh\\Cli\\Service\\Config\:\:validateSessionId\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Session/SessionSwitchCommand.php - - - - message: '#^Binary operation "\." between ''The file already…'' and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Command/SshKey/SshKeyAddCommand.php - - - - message: '#^Method Platformsh\\Cli\\Command\\SshKey\\SshKeyAddCommand\:\:askNewKeyPath\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Command/SshKey/SshKeyAddCommand.php - - - - message: '#^Parameter \#1 \$filename of function file_exists expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/SshKey/SshKeyAddCommand.php - - - - message: '#^Parameter \#1 \$haystack of function str_starts_with expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/SshKey/SshKeyAddCommand.php - - - - message: '#^Parameter \#1 \$string of function ltrim expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/SshKey/SshKeyAddCommand.php - - - - message: '#^Parameter \#1 \$string of function substr expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/SshKey/SshKeyAddCommand.php - - message: '#^Cannot access offset ''command'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -3162,36 +2706,6 @@ parameters: count: 1 path: src/Command/Team/Project/TeamProjectAddCommand.php - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 1 - path: src/Command/Team/Project/TeamProjectAddCommand.php - - - - message: '#^Parameter \#1 \$project of method Platformsh\\Cli\\Service\\Api\:\:getProjectLabel\(\) expects Platformsh\\Client\\Model\\BasicProjectInfo\|Platformsh\\Client\\Model\\Organization\\Project\|Platformsh\\Client\\Model\\Project\|Platformsh\\Client\\Model\\Team\\TeamProjectAccess\|string, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Team/Project/TeamProjectAddCommand.php - - - - message: '#^Parameter \#1 \$projectIds of method Platformsh\\Cli\\Command\\Team\\Project\\TeamProjectAddCommand\:\:displayProjectsAsList\(\) expects array\, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Team/Project/TeamProjectAddCommand.php - - - - message: '#^Parameter \#1 \$projectIds of method Platformsh\\Cli\\Command\\Team\\Project\\TeamProjectAddCommand\:\:displayProjectsAsList\(\) expects array\, list\ given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Team/Project/TeamProjectAddCommand.php - - - - message: '#^Possibly invalid array key type mixed\.$#' - identifier: offsetAccess.invalidOffset - count: 1 - path: src/Command/Team/Project/TeamProjectAddCommand.php - - message: '#^Cannot access offset ''sort'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -3204,12 +2718,6 @@ parameters: count: 1 path: src/Command/Team/TeamCreateCommand.php - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 1 - path: src/Command/Team/TeamCreateCommand.php - - message: '#^Method Platformsh\\Cli\\Command\\Team\\TeamCreateCommand\:\:showProjectRoleForm\(\) should return string but returns mixed\.$#' identifier: return.type @@ -3228,12 +2736,6 @@ parameters: count: 1 path: src/Command/Team/TeamCreateCommand.php - - - message: '#^Parameter \#1 \$label of method Platformsh\\Client\\Model\\Organization\\Organization\:\:createTeam\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Team/TeamCreateCommand.php - - message: '#^Parameter \#1 \$roles of method Platformsh\\Cli\\Command\\Team\\TeamCreateCommand\:\:describeRoleInput\(\) expects array\, array given\.$#' identifier: argument.type @@ -3270,18 +2772,6 @@ parameters: count: 1 path: src/Command/Team/TeamCreateCommand.php - - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Team/TeamCreateCommand.php - - - - message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Team/TeamCreateCommand.php - - message: '#^Parameter \#2 \$data of method Platformsh\\Cli\\Service\\PropertyFormatter\:\:displayData\(\) expects array\, array given\.$#' identifier: argument.type @@ -3336,30 +2826,6 @@ parameters: count: 1 path: src/Command/Team/TeamListCommand.php - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 1 - path: src/Command/Team/User/TeamUserAddCommand.php - - - - message: '#^Parameter \#1 \$argument of static method Platformsh\\Cli\\Util\\OsUtil\:\:escapeShellArg\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Team/User/TeamUserAddCommand.php - - - - message: '#^Parameter \#2 \$email of method Platformsh\\Cli\\Service\\Api\:\:loadMemberByEmail\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Team/User/TeamUserAddCommand.php - - - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Team/User/TeamUserAddCommand.php - - message: '#^Cannot access offset ''page\[size\]'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -4506,12 +3972,6 @@ parameters: count: 1 path: src/Selector/Selector.php - - - message: '#^Method Platformsh\\Cli\\Selector\\Selector\:\:offerProjectChoice\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Selector/Selector.php - - message: '#^Parameter \#1 \$id of method Platformsh\\Cli\\Service\\Api\:\:getEnvironment\(\) expects string, mixed given\.$#' identifier: argument.type @@ -4554,12 +4014,6 @@ parameters: count: 1 path: src/Selector/Selector.php - - - message: '#^Possibly invalid array key type mixed\.$#' - identifier: offsetAccess.invalidOffset - count: 1 - path: src/Selector/Selector.php - - message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' identifier: foreach.nonIterable @@ -5670,54 +5124,6 @@ parameters: count: 1 path: src/Service/RemoteEnvVars.php - - - message: '#^Cannot access offset ''disk'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/ResourcesUtil.php - - - - message: '#^Cannot access offset ''minimum'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/ResourcesUtil.php - - - - message: '#^Cannot access offset ''profile_size'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/ResourcesUtil.php - - - - message: '#^Cannot access offset mixed on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/ResourcesUtil.php - - - - message: '#^Method Platformsh\\Cli\\Service\\ResourcesUtil\:\:allServices\(\) should return array\ but returns array\\.$#' - identifier: return.type - count: 1 - path: src/Service/ResourcesUtil.php - - - - message: '#^Method Platformsh\\Cli\\Service\\ResourcesUtil\:\:sizeInfo\(\) should return array\{cpu\: string, memory\: string\}\|null but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Service/ResourcesUtil.php - - - - message: '#^Parameter \#1 \$id of method Platformsh\\Cli\\Service\\Api\:\:getOrganizationById\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Service/ResourcesUtil.php - - - - message: '#^Possibly invalid array key type mixed\.$#' - identifier: offsetAccess.invalidOffset - count: 2 - path: src/Service/ResourcesUtil.php - - message: '#^Method Platformsh\\Cli\\Service\\Rsync\:\:supportsConvertingFilenames\(\) should return bool\|null but returns mixed\.$#' identifier: return.type diff --git a/legacy/src/Command/Resources/Build/BuildResourcesGetCommand.php b/legacy/src/Command/Resources/Build/BuildResourcesGetCommand.php index db8bfd1c5..512d07484 100644 --- a/legacy/src/Command/Resources/Build/BuildResourcesGetCommand.php +++ b/legacy/src/Command/Resources/Build/BuildResourcesGetCommand.php @@ -48,7 +48,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $project = $selection->getProject(); - $settings = $project->getSettings(); + $current = $this->resourcesUtil->buildResources($project->getSettings()); $isOriginalCommand = $input instanceof ArgvInput; @@ -58,8 +58,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $rows = [ [ - 'cpu' => $settings['build_resources']['cpu'], - 'memory' => $settings['build_resources']['memory'], + 'cpu' => $current['cpu'], + 'memory' => $current['memory'], ], ]; diff --git a/legacy/src/Command/Resources/Build/BuildResourcesSetCommand.php b/legacy/src/Command/Resources/Build/BuildResourcesSetCommand.php index 868d9d7b5..43b5a6030 100644 --- a/legacy/src/Command/Resources/Build/BuildResourcesSetCommand.php +++ b/legacy/src/Command/Resources/Build/BuildResourcesSetCommand.php @@ -46,12 +46,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $capabilities = $this->api->getProjectCapabilities($project); $capability = $capabilities->getProperty('build_resources', false); - $maxCpu = $capability ? $capability['max_cpu'] : null; - $maxMemory = $capability ? $capability['max_memory'] : null; + $maxCpu = is_array($capability) ? $capability['max_cpu'] ?? null : null; + $maxCpu = is_int($maxCpu) || is_float($maxCpu) ? (float) $maxCpu : null; + $maxMemory = is_array($capability) ? $capability['max_memory'] ?? null : null; + $maxMemory = is_int($maxMemory) ? $maxMemory : null; $settings = $project->getSettings(); + $current = $this->resourcesUtil->buildResources($settings); - $validateCpu = function ($v) use ($maxCpu): float { + $validateCpu = function (string $v) use ($maxCpu): float { $f = (float) $v; if ($f != $v) { throw new InvalidArgumentException('The CPU value must be a number'); @@ -59,12 +62,12 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($f < 0.1) { throw new InvalidArgumentException(sprintf('The minimum allowed CPU is %.1f', 0.1)); } - if ($f > $maxCpu) { + if ($maxCpu !== null && $f > $maxCpu) { throw new InvalidArgumentException(sprintf('The maximum allowed CPU is %.1f', $maxCpu)); } return $f; }; - $validateMemory = function ($v) use ($maxMemory): int { + $validateMemory = function (string $v) use ($maxMemory): int { $i = (int) $v; if ($i != $v) { throw new InvalidArgumentException('The memory value must be an integer'); @@ -72,7 +75,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($i < 64) { throw new InvalidArgumentException(sprintf('The minimum allowed memory is %d MB', 64)); } - if ($i > $maxMemory) { + if ($maxMemory !== null && $i > $maxMemory) { throw new InvalidArgumentException(sprintf('The maximum allowed memory is %d MB', $maxMemory)); } return $i; @@ -101,7 +104,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($cpuOption === null && $memoryOption === null) { $cpuOption = $this->questionHelper->askInput( 'CPU size', - $this->resourcesUtil->formatCPU($settings['build_resources']['cpu']), + $this->resourcesUtil->formatCPU($current['cpu']), [], $validateCpu, 'current: ', @@ -109,7 +112,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $memoryOption = $this->questionHelper->askInput( 'Memory size in MB', - $settings['build_resources']['memory'], + $current['memory'], [], $validateMemory, 'current: ', @@ -118,10 +121,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $updates = []; - if ($cpuOption !== null && $cpuOption !== $settings['build_resources']['cpu']) { + if ($cpuOption !== null && $cpuOption !== $current['cpu']) { $updates['build_resources']['cpu'] = $cpuOption; } - if ($memoryOption !== null && $memoryOption !== $settings['build_resources']['memory']) { + if ($memoryOption !== null && $memoryOption !== $current['memory']) { $updates['build_resources']['memory'] = $memoryOption; } @@ -130,7 +133,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 0; } - $this->summarizeChanges($updates['build_resources'], $settings['build_resources']); + $this->summarizeChanges($updates['build_resources'], $current); $this->io->debug('Raw updates: ' . json_encode($updates, JSON_UNESCAPED_SLASHES)); @@ -150,8 +153,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * Summarizes all the changes that would be made. * - * @param array{cpu?: int, memory?: int} $updates - * @param array{cpu: int, memory: int} $current + * @param array{cpu?: float, memory?: int} $updates + * @param array{cpu: float, memory: int} $current * @return void */ private function summarizeChanges(array $updates, array $current): void diff --git a/legacy/src/Command/Resources/ResourcesCommandBase.php b/legacy/src/Command/Resources/ResourcesCommandBase.php index db10c5665..99444c20f 100644 --- a/legacy/src/Command/Resources/ResourcesCommandBase.php +++ b/legacy/src/Command/Resources/ResourcesCommandBase.php @@ -5,15 +5,14 @@ namespace Platformsh\Cli\Command\Resources; use Platformsh\Cli\Command\CommandBase; -use Platformsh\Cli\Console\ArrayArgument; use Platformsh\Cli\Service\Config; -use Platformsh\Cli\Util\Wildcard; -use Platformsh\Client\Model\Deployment\Service; -use Platformsh\Client\Model\Deployment\WebApp; -use Platformsh\Client\Model\Deployment\Worker; -use Symfony\Component\Console\Input\InputInterface; +use Platformsh\Cli\Service\ResourcesUtil; use Symfony\Contracts\Service\Attribute\Required; +/** + * @phpstan-import-type ProfileSize from ResourcesUtil + * @phpstan-import-type ContainerProfiles from ResourcesUtil + */ class ResourcesCommandBase extends CommandBase { private Config $config; @@ -29,182 +28,27 @@ public function isHidden(): bool return !$this->config->getBool('api.sizing') || parent::isHidden(); } - /** - * Checks whether a service needs a persistent disk. - * - * @param WebApp|Service|Worker $service - * @return bool - */ - protected function supportsDisk($service) - { - // Workers use the disk of their parent app. - if ($service instanceof Worker) { - return false; - } - return isset($service->getProperties()['resources']['minimum']['disk']); - } - - /** - * Filters a list of services according to the --service or --type options. - * - * @param array $services - * @param InputInterface $input - * - * @return WebApp[]|Service[]|Worker[]|false - * False on error, or an array of services. - */ - protected function filterServices($services, InputInterface $input) - { - $selectedNames = []; - - $requestedServices = ArrayArgument::getOption($input, 'service'); - if (!empty($requestedServices)) { - $selectedNames = Wildcard::select(array_keys($services), $requestedServices); - if (!$selectedNames) { - $this->stdErr->writeln('No services were found matching the name(s): ' . implode(', ', $requestedServices) . ''); - return false; - } - $services = array_intersect_key($services, array_flip($selectedNames)); - } - $requestedApps = ArrayArgument::getOption($input, 'app'); - if (!empty($requestedApps)) { - $selectedNames = Wildcard::select(array_keys(array_filter($services, function ($s) { - return $s instanceof WebApp; - })), $requestedApps); - if (!$selectedNames) { - $this->stdErr->writeln('No applications were found matching the name(s): ' . implode(', ', $requestedApps) . ''); - return false; - } - $services = array_intersect_key($services, array_flip($selectedNames)); - } - $requestedWorkers = ArrayArgument::getOption($input, 'worker'); - if (!empty($requestedWorkers)) { - $selectedNames = Wildcard::select(array_keys(array_filter($services, function ($s) { - return $s instanceof Worker; - })), $requestedWorkers); - if (!$selectedNames) { - $this->stdErr->writeln('No workers were found matching the name(s): ' . implode(', ', $requestedWorkers) . ''); - return false; - } - $services = array_intersect_key($services, array_flip($selectedNames)); - } - - if ($input->hasOption('type') && ($requestedTypes = ArrayArgument::getOption($input, 'type'))) { - $byType = []; - foreach ($services as $name => $service) { - $type = $service->type; - [$prefix] = explode(':', $service->type, 2); - $byType[$type][] = $name; - $byType[$prefix][] = $name; - } - $selectedTypes = Wildcard::select(array_keys($byType), $requestedTypes); - if (!$selectedTypes) { - $this->stdErr->writeln('No services were found matching the type(s): ' . implode(', ', $requestedTypes) . ''); - return false; - } - foreach ($selectedTypes as $selectedType) { - $selectedNames = array_merge($selectedNames, $byType[$selectedType]); - } - $services = array_intersect_key($services, array_flip($selectedNames)); - } - - return $services; - } - - /** - * Returns container profile size info, given service properties. - * - * @param array $properties - * The service properties (e.g. from $service->getProperties()). - * @param array>> $containerProfiles - * The list of container profiles (e.g. from - * $deployment->container_profiles). - * - * @return array|null - */ - protected function sizeInfo(array $properties, array $containerProfiles) - { - if (isset($properties['resources']['profile_size'])) { - $size = $properties['resources']['profile_size']; - $profile = $properties['container_profile']; - if (isset($containerProfiles[$profile][$size])) { - return $containerProfiles[$profile][$size]; - } - } - return null; - } - - /** - * Formats a change in a value. - * - * @param int|float|string|null $previousValue - * @param int|float|string|null $newValue - * @param string $suffix A unit suffix e.g. ' MB' - * - * @return string - */ - protected function formatChange($previousValue, $newValue, $suffix = '') - { - if ($previousValue === null || $newValue === $previousValue) { - return sprintf('%s%s', $newValue, $suffix); - } - return sprintf( - '%s from %s%s to %s%s', - $newValue > $previousValue ? 'increasing' : 'decreasing', - $previousValue, - $suffix, - $newValue, - $suffix - ); - } - - /** - * Formats a CPU amount. - * - * @param int|float|string $unformatted - * - * @return string - * A numeric (still comparable) string with 1 decimal place. - */ - protected function formatCPU($unformatted) - { - return sprintf('%.1f', $unformatted); - } - /** * Format CPU Type. * - * @param array|null $sizeInfo - * - * @return string + * @param ProfileSize $sizeInfo */ - protected function formatCPUType($sizeInfo) + protected function formatCPUType(array $sizeInfo): string { - $size = $sizeInfo ? $sizeInfo['cpu'] : null; - if ($size === null) { - return ""; - } - - if (!isset($sizeInfo['cpu_type'])) { - return ""; - } - return sprintf('(%s)', $sizeInfo['cpu_type']); } /** * Sort container profiles by size. * - * @param array>> $profiles + * @param ContainerProfiles $profiles * - * @return array>> + * @return ContainerProfiles */ - protected function sortContainerProfiles(array $profiles) + protected function sortContainerProfiles(array $profiles): array { foreach ($profiles as &$profile) { - uasort($profile, function ($a, $b) { - return $a['cpu'] == $b['cpu'] ? 0 : ($a['cpu'] > $b['cpu'] ? 1 : -1); - }); + uasort($profile, fn(array $a, array $b): int => $a['cpu'] <=> $b['cpu']); } return $profiles; diff --git a/legacy/src/Command/Resources/ResourcesGetCommand.php b/legacy/src/Command/Resources/ResourcesGetCommand.php index 126a86b92..0ec9526f0 100644 --- a/legacy/src/Command/Resources/ResourcesGetCommand.php +++ b/legacy/src/Command/Resources/ResourcesGetCommand.php @@ -112,7 +112,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $empty = $this->table->formatIsMachineReadable() ? '' : 'not set'; $notApplicable = $this->table->formatIsMachineReadable() ? '' : 'N/A'; - $containerProfiles = $this->sortContainerProfiles($nextDeployment->container_profiles); + $containerProfiles = $this->sortContainerProfiles($this->resourcesUtil->containerProfiles($nextDeployment)); $rows = []; $cpuTypeOption = Option::stringOrNull($input, 'cpu-type'); @@ -121,8 +121,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $hasObjectStorage = false; foreach ($services as $name => $service) { $properties = $service->getProperties(); - // Tasks may not specify a profile yet; default so CPU/memory still resolve. - $containerProfile = $properties['container_profile'] ?? ($service instanceof Task ? 'BALANCED' : null); + $resources = $this->resourcesUtil->resources($service); + $containerProfile = $this->resourcesUtil->containerProfile($service); if (!$this->table->formatIsMachineReadable() && !empty($autoscalingEnabled[$name])) { $name .= ' ' . $autoscalingIndicator; $hasAutoscalingIndicator = true; @@ -143,22 +143,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'memory' => $empty, ]; - $profileSize = $properties['resources']['profile_size'] ?? null; + $profileSize = $resources['profile_size'] ?? null; if ($containerProfile !== null && $profileSize !== null && isset($containerProfiles[$containerProfile][$profileSize])) { $profileInfo = $containerProfiles[$containerProfile][$profileSize]; - if ($cpuTypeOption != "" && isset($profileInfo['cpu_type']) && $profileInfo['cpu_type'] != $cpuTypeOption) { + if ($cpuTypeOption != "" && $profileInfo['cpu_type'] != $cpuTypeOption) { continue; } - $row['cpu_type'] = $profileInfo['cpu_type'] ?? ''; - $row['cpu'] = isset($profileInfo['cpu']) ? $this->resourcesUtil->formatCPU($profileInfo['cpu']) : ''; - $row['memory'] = isset($profileInfo['cpu']) ? $profileInfo['memory'] : ''; + $row['cpu_type'] = $profileInfo['cpu_type']; + $row['cpu'] = $this->resourcesUtil->formatCPU($profileInfo['cpu']); + $row['memory'] = $profileInfo['memory']; } - if (!empty($properties['resources'])) { - foreach ($properties['resources'] as $key => $value) { - if (isset($row[$key])) { - $row[$key] = $value === null ? '' : $value; + if (isset($properties['resources']) && is_array($properties['resources'])) { + foreach (['profile_size', 'base_memory', 'memory_ratio'] as $key) { + if (array_key_exists($key, $properties['resources'])) { + $row[$key] = $resources[$key] ?? ''; } } } @@ -166,19 +166,20 @@ protected function execute(InputInterface $input, OutputInterface $output): int if (!$this->resourcesUtil->supportsDisk($service)) { $row['disk'] = $notApplicable; } elseif (array_key_exists('disk', $properties)) { - if (empty($properties['disk'])) { + $disk = $this->resourcesUtil->disk($service); + if (empty($disk)) { $row['disk'] = empty($properties['resources']) ? $empty : ''; } else { - $row['disk'] = $this->propertyFormatter->format($properties['disk'], 'disk'); + $row['disk'] = $this->propertyFormatter->format($disk, 'disk'); } } // Object storage is only available on apps. if (!$service instanceof WebApp) { $row['object_storage'] = $notApplicable; - } elseif (isset($properties['resources']['disk']['object'])) { - $row['object_storage'] = (string) $properties['resources']['disk']['object']; - if ($properties['resources']['disk']['object'] > 0) { + } elseif (isset($resources['object_storage'])) { + $row['object_storage'] = (string) $resources['object_storage']; + if ($resources['object_storage'] > 0) { $hasObjectStorage = true; } } @@ -187,7 +188,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($service instanceof Task) { $row['instance_count'] = $notApplicable; } else { - $row['instance_count'] = isset($properties['instance_count']) ? $this->propertyFormatter->format($properties['instance_count'], 'instance_count') : '1'; + $instanceCount = $this->resourcesUtil->instanceCount($service); + $row['instance_count'] = $instanceCount !== null ? $this->propertyFormatter->format($instanceCount, 'instance_count') : '1'; } $rows[] = $row; diff --git a/legacy/src/Command/Resources/ResourcesSetCommand.php b/legacy/src/Command/Resources/ResourcesSetCommand.php index 071303163..45438505b 100644 --- a/legacy/src/Command/Resources/ResourcesSetCommand.php +++ b/legacy/src/Command/Resources/ResourcesSetCommand.php @@ -30,6 +30,12 @@ use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; +/** + * @phpstan-import-type ContainerProfiles from ResourcesUtil + * @phpstan-import-type ProfileSize from ResourcesUtil + * @phpstan-type ServiceUpdates array{resources?: array{profile_size?: string, disk?: array{object: int}}, instance_count?: int, disk?: int} + * @phpstan-type CurrentService array{profile_size: string|null, instance_count: int|null, disk: int|null, sizes: array} + */ #[AsCommand(name: 'resources:set', description: 'Set the resources of apps, tasks and services on an environment')] class ResourcesSetCommand extends ResourcesCommandBase { @@ -144,8 +150,10 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Determine the limit of the number of instances, which can vary per project. $instanceLimit = null; - if (($projectInfo = $nextDeployment->getProperty('project_info')) && isset($projectInfo['capabilities']['instance_limit'])) { - $instanceLimit = $projectInfo['capabilities']['instance_limit']; + $projectInfo = $nextDeployment->getProperty('project_info', false); + if (is_array($projectInfo) && isset($projectInfo['capabilities']) && is_array($projectInfo['capabilities'])) { + $limit = $projectInfo['capabilities']['instance_limit'] ?? null; + $instanceLimit = is_int($limit) ? $limit : null; } $autoscalingEnabled = []; @@ -158,18 +166,18 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Validate the --size option. - [$givenSizes, $errored] = $this->parseSetting($input, 'size', $services, fn($v, $serviceName, $service) => $this->validateProfileSize($v, $serviceName, $service, $nextDeployment)); + [$givenSizes, $errored] = $this->parseSetting($input, 'size', $services, fn(string $v, string $serviceName, WebApp|Worker|Service|Task $service): string => $this->validateProfileSize($v, $serviceName, $service, $nextDeployment)); // Validate the --count option. - [$givenCounts, $countErrored] = $this->parseSetting($input, 'count', $services, fn($v, $serviceName, $service) => $this->validateInstanceCount($v, $serviceName, $service, $instanceLimit, !empty($autoscalingEnabled[$serviceName]))); + [$givenCounts, $countErrored] = $this->parseSetting($input, 'count', $services, fn(string $v, string $serviceName, WebApp|Worker|Service|Task $service): int => $this->validateInstanceCount($v, $serviceName, $service, $instanceLimit, !empty($autoscalingEnabled[$serviceName]))); $errored = $errored || $countErrored; // Validate the --disk option. - [$givenDiskSizes, $diskErrored] = $this->parseSetting($input, 'disk', $services, fn($v, $serviceName, $service) => $this->validateDiskSize($v, $serviceName, $service)); + [$givenDiskSizes, $diskErrored] = $this->parseSetting($input, 'disk', $services, fn(string $v, string $serviceName, WebApp|Worker|Service|Task $service): int => $this->validateDiskSize($v, $serviceName, $service)); $errored = $errored || $diskErrored; // Validate the --object-storage option. - [$givenObjectStorage, $objectStorageErrored] = $this->parseSetting($input, 'object-storage', $services, fn($v, $serviceName, $service) => $this->validateObjectStorage($v, $serviceName, $service)); + [$givenObjectStorage, $objectStorageErrored] = $this->parseSetting($input, 'object-storage', $services, fn(string $v, string $serviceName, WebApp|Worker|Service|Task $service): int => $this->validateObjectStorage($v, $serviceName, $service)); $errored = $errored || $objectStorageErrored; if ($errored) { return 1; @@ -183,7 +191,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $this->stdErr->writeln(''); - $containerProfiles = $nextDeployment->container_profiles; + $allContainerProfiles = $this->resourcesUtil->containerProfiles($nextDeployment); + $containerProfiles = $allContainerProfiles; // Remove guaranteed profiles if project does not support it. $supportsGuaranteedCPU = $this->api->supportsGuaranteedCPU($selection->getProject(), $nextDeployment); @@ -209,14 +218,17 @@ protected function execute(InputInterface $input, OutputInterface $output): int $type = $this->typeName($service); $group = $this->group($service); - $properties = $service->getProperties(); - // Tasks may lack a profile, instance_count and disk; default safely. - $containerProfile = $properties['container_profile'] ?? ($service instanceof Task ? 'BALANCED' : null); - $current[$group][$name]['resources']['profile_size'] = $properties['resources']['profile_size'] ?? null; - $current[$group][$name]['instance_count'] = $properties['instance_count'] ?? null; - $current[$group][$name]['disk'] = $properties['disk'] ?? null; - // Unfiltered, so that usage is also known for a current size that is no longer offered. - $current[$group][$name]['sizes'] = $nextDeployment->container_profiles[$containerProfile] ?? []; + $resources = $this->resourcesUtil->resources($service); + $currentSize = $resources['profile_size'] ?? null; + $currentDisk = $this->resourcesUtil->disk($service); + $containerProfile = $this->resourcesUtil->containerProfile($service); + $current[$group][$name] = [ + 'profile_size' => $currentSize, + 'instance_count' => $this->resourcesUtil->instanceCount($service), + 'disk' => $currentDisk, + // Unfiltered, so that usage is also known for a current size that is no longer offered. + 'sizes' => $allContainerProfiles[$containerProfile] ?? [], + ]; $header = '' . ucfirst($type) . ': ' . $name . ''; $headerShown = false; @@ -230,34 +242,27 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Set the profile size. if (isset($givenSizes[$name])) { - if (!isset($properties['resources']['profile_size']) || $givenSizes[$name] != $properties['resources']['profile_size']) { + if ($currentSize === null || $givenSizes[$name] != $currentSize) { $updates[$group][$name]['resources']['profile_size'] = $givenSizes[$name]; } } elseif ($showCompleteForm - || (!isset($properties['resources']['profile_size']) && $input->isInteractive())) { + || ($currentSize === null && $input->isInteractive())) { if ($containerProfile !== null) { $header .= "\n" . sprintf('Container profile: %s', $containerProfile); } $ensureHeader(); - $new = isset($properties['resources']['profile_size']) ? 'a new' : 'a'; + $new = $currentSize !== null ? 'a new' : 'a'; $profileSizes = $containerProfiles[$containerProfile] ?? []; - if (isset($properties['resources']['profile_size'])) { - $defaultOption = $properties['resources']['profile_size']; - } elseif (isset($properties['resources']['default']['profile_size'])) { - $defaultOption = $properties['resources']['default']['profile_size']; - } else { - $defaultOption = null; - } + $defaultOption = $currentSize ?? $resources['default']['profile_size'] ?? null; $options = []; foreach ($profileSizes as $profileSize => $sizeInfo) { // Skip showing sizes that are below the minimum for this service. - if ((isset($properties['resources']['minimum']['cpu']) && $sizeInfo['cpu'] < $properties['resources']['minimum']['cpu']) - || (isset($properties['resources']['minimum']['memory']) && $sizeInfo['memory'] < $properties['resources']['minimum']['memory'])) { + if ((isset($resources['minimum']['cpu']) && $sizeInfo['cpu'] < $resources['minimum']['cpu']) + || (isset($resources['minimum']['memory']) && $sizeInfo['memory'] < $resources['minimum']['memory'])) { continue; } $description = sprintf('CPU %s, memory %s MB (%s)', $sizeInfo['cpu'], $sizeInfo['memory'], $sizeInfo['cpu_type']); - if (isset($properties['resources']['profile_size']) - && $profileSize == $properties['resources']['profile_size']) { + if ($currentSize !== null && $profileSize == $currentSize) { $description .= ' (current)'; } elseif ($defaultOption !== null && $defaultOption === (string) $profileSize) { $description .= ' (default)'; @@ -266,16 +271,16 @@ protected function execute(InputInterface $input, OutputInterface $output): int $options[$profileSize] = $description; } - if (!isset($properties['resources']['profile_size']) && empty($options)) { + if ($currentSize === null && empty($options)) { $this->stdErr->writeln(sprintf('No profile size can be found for the %s %s which satisfies its minimum resources.', $type, $name)); $errored = true; } else { $profileSize = $this->questionHelper->chooseAssoc($options, sprintf('Choose %s profile size:', $new), $defaultOption, false, false); - if (!isset($properties['resources']['profile_size']) || $profileSize !== $properties['resources']['profile_size']) { + if ($profileSize !== $currentSize) { $updates[$group][$name]['resources']['profile_size'] = $profileSize; } } - } elseif (!isset($properties['resources']['profile_size'])) { + } elseif ($currentSize === null) { $this->stdErr->writeln(sprintf('A profile size is required for the %s %s.', $type, $name)); $errored = true; } @@ -294,7 +299,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // This is not applicable to a Task or a non-scalable Service, and unavailable when autoscaling is enabled. if ($this->supportsInstanceCount($service) && empty($autoscalingEnabled[$name])) { // An unset instance count means 1. - $currentCount = $properties['instance_count'] ?? 1; + $currentCount = $current[$group][$name]['instance_count'] ?? 1; if (isset($givenCounts[$name])) { $instanceCount = $givenCounts[$name]; if ($instanceCount !== $currentCount) { @@ -317,22 +322,19 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Set the disk size. if ($this->resourcesUtil->supportsDisk($service)) { + $diskIsRequiredButUnset = $this->diskIsRequiredButUnset($service); if (isset($givenDiskSizes[$name])) { - if ($givenDiskSizes[$name] !== ($properties['disk'] ?? null)) { + if ($givenDiskSizes[$name] !== $currentDisk) { $updates[$group][$name]['disk'] = $givenDiskSizes[$name]; } - } elseif ($showCompleteForm || ($this->diskIsRequiredButUnset($properties) && $input->isInteractive())) { + } elseif ($showCompleteForm || ($diskIsRequiredButUnset && $input->isInteractive())) { $ensureHeader(); - if (($properties['disk'] ?? null)) { - $default = $properties['disk']; - } else { - $default = $properties['resources']['default']['disk'] ?? '512'; - } + $default = $currentDisk ?: $resources['default']['disk'] ?? 512; $diskSize = $this->questionHelper->askInput('Enter a disk size in MB', (string) $default, ['512', '1024', '2048'], fn($v) => $this->validateDiskSize($v, $name, $service)); - if ($diskSize !== ($properties['disk'] ?? null)) { + if ($diskSize !== $currentDisk) { $updates[$group][$name]['disk'] = $diskSize; } - } elseif ($this->diskIsRequiredButUnset($properties)) { + } elseif ($diskIsRequiredButUnset) { $this->stdErr->writeln(sprintf('A disk size is required for the %s %s.', $type, $name)); $errored = true; } @@ -340,7 +342,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int // Set the object storage size (apps only). if ($service instanceof WebApp && isset($givenObjectStorage[$name])) { - $currentObject = $properties['resources']['disk']['object'] ?? null; + $currentObject = $resources['object_storage'] ?? null; if ($givenObjectStorage[$name] !== $currentObject) { $updates[$group][$name]['resources']['disk']['object'] = $givenObjectStorage[$name]; } @@ -437,9 +439,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int /** * Summarizes all the changes that would be made. * - * @param array>> $updates + * @param array> $updates * @param array $services - * @param array $containerProfiles + * @param ContainerProfiles $containerProfiles * @return void */ private function summarizeChanges(array $updates, array $services, array $containerProfiles): void @@ -455,20 +457,18 @@ private function summarizeChanges(array $updates, array $services, array $contai /** * Summarizes changes per service. * - * @param array $updates - * @param array $containerProfiles + * @param ServiceUpdates $updates + * @param ContainerProfiles $containerProfiles */ private function summarizeChangesPerService(string $name, WebApp|Worker|Service|Task $service, array $updates, array $containerProfiles): void { $this->stdErr->writeln(sprintf(' %s: %s', ucfirst($this->typeName($service)), $name)); - $properties = $service->getProperties(); - $properties['container_profile'] ??= $service instanceof Task ? 'BALANCED' : null; + $resources = $this->resourcesUtil->resources($service); if (isset($updates['resources']['profile_size'])) { - $sizeInfo = $this->resourcesUtil->sizeInfo($properties, $containerProfiles); - $newProperties = array_replace_recursive($properties, $updates); - - $newSizeInfo = $this->resourcesUtil->sizeInfo($newProperties, $containerProfiles); + $sizes = $containerProfiles[$this->resourcesUtil->containerProfile($service)] ?? []; + $sizeInfo = isset($resources['profile_size']) ? $sizes[$resources['profile_size']] ?? null : null; + $newSizeInfo = $sizes[$updates['resources']['profile_size']] ?? null; if ($newSizeInfo === null) { // The requested profile_size isn't in the deployment's // container_profiles catalog, so we can't show CPU/memory @@ -492,20 +492,20 @@ private function summarizeChangesPerService(string $name, WebApp|Worker|Service| } if (isset($updates['instance_count'])) { $this->stdErr->writeln(' Instance count: ' . $this->resourcesUtil->formatChange( - $properties['instance_count'] ?? 1, + $this->resourcesUtil->instanceCount($service) ?? 1, $updates['instance_count'], )); } if (isset($updates['disk'])) { $this->stdErr->writeln(' Disk: ' . $this->resourcesUtil->formatChange( - $properties['disk'] ?? null, + $this->resourcesUtil->disk($service), $updates['disk'], ' MB', )); } if (isset($updates['resources']['disk']['object'])) { $this->stdErr->writeln(' Object storage: ' . $this->resourcesUtil->formatChange( - $properties['resources']['disk']['object'] ?? null, + $resources['object_storage'] ?? null, $updates['resources']['disk']['object'], ' MB', )); @@ -593,13 +593,11 @@ protected function validateInstanceCount(string $value, string $serviceName, Web * * A zero minimum means a disk is allowed but optional, as on Upsun, so * leaving it unset is a valid final state rather than something to fix. - * - * @param array $properties */ - protected function diskIsRequiredButUnset(array $properties): bool + protected function diskIsRequiredButUnset(WebApp|Worker|Service|Task $service): bool { - return !empty($properties['resources']['minimum']['disk']) - && empty($properties['disk'] ?? null); + return !empty($this->resourcesUtil->resources($service)['minimum']['disk']) + && empty($this->resourcesUtil->disk($service)); } /** @@ -727,15 +725,17 @@ protected function validateProfileSize(string $value, string $serviceName, WebAp /** * Parses a user's list of settings for --size, --instances or --disk. * + * @template T of int|string + * * @param InputInterface $input * @param string $optionName The input option name. * @param array $services - * @param callable $validator + * @param callable(string, string, Service|WebApp|Worker|Task): T $validator * Validate the value. The callback takes the arguments ($value, * $serviceName, $service) and returns a normalized value or throws * an InvalidArgumentException. * - * @return array{array, bool} + * @return array{array, bool} * An array of settings per service, and whether an error occurred. */ private function parseSetting(InputInterface $input, string $optionName, array $services, callable $validator): array @@ -845,8 +845,8 @@ private function trialResourceLimits(Project $project): array * Compute the total memory/CPU/storage diff that will occur when the given update * is applied. * - * @param array>> $updates - * @param array>> $current + * @param array> $updates + * @param array> $current * * @return array{memory: int|float, cpu: int|float, disk: int|float} */ @@ -859,16 +859,10 @@ private function computeMemoryCPUStorageDiff(array $updates, array $current): ar ]; foreach ($updates as $group => $groupUpdates) { foreach ($groupUpdates as $serviceName => $serviceUpdates) { - if (isset($current[$group][$serviceName]['instance_count']) === false) { - $current[$group][$serviceName]['instance_count'] = 1; - } - if (isset($current[$group][$serviceName]['disk']) === false) { - $current[$group][$serviceName]['disk'] = 0; - } - - $currentCount = $current[$group][$serviceName]['instance_count']; - $currentSize = $current[$group][$serviceName]['resources']['profile_size']; - $currentStorage = $current[$group][$serviceName]['disk']; + $currentService = $current[$group][$serviceName]; + $currentCount = $currentService['instance_count'] ?? 1; + $currentSize = $currentService['profile_size']; + $currentStorage = $currentService['disk'] ?? 0; $newCount = $currentCount; $newSize = $currentSize; @@ -883,8 +877,7 @@ private function computeMemoryCPUStorageDiff(array $updates, array $current): ar $newStorage = $serviceUpdates['disk']; } - $currentService = $current[$group][$serviceName]; - $currentProfile = $currentService['sizes'][$currentSize] ?? []; + $currentProfile = $currentSize !== null ? $currentService['sizes'][$currentSize] ?? [] : []; $currentCPU = $currentCount * ($currentProfile['cpu'] ?? 0); $currentRAM = $currentCount * ($currentProfile['memory'] ?? 0); diff --git a/legacy/src/Command/Resources/ResourcesSizeListCommand.php b/legacy/src/Command/Resources/ResourcesSizeListCommand.php index 4014e7b64..62b828208 100644 --- a/legacy/src/Command/Resources/ResourcesSizeListCommand.php +++ b/legacy/src/Command/Resources/ResourcesSizeListCommand.php @@ -64,10 +64,13 @@ protected function execute(InputInterface $input, OutputInterface $output): int } $servicesByProfile = []; foreach ($services as $name => $service) { - $servicesByProfile[$service->container_profile][] = $name; + $profile = $this->resourcesUtil->containerProfile($service); + if ($profile !== null) { + $servicesByProfile[$profile][] = $name; + } } - $containerProfiles = $this->sortContainerProfiles($nextDeployment->container_profiles); + $containerProfiles = $this->sortContainerProfiles($this->resourcesUtil->containerProfiles($nextDeployment)); if ($serviceOption = Option::stringOrNull($input, 'service')) { if (!isset($services[$serviceOption])) { @@ -75,7 +78,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int return 1; } $service = $services[$serviceOption]; - $profile = $service->container_profile; + $profile = $this->resourcesUtil->containerProfile($service); } elseif ($profileOption = Option::stringOrNull($input, 'profile')) { $profile = $profileOption; if (!isset($containerProfiles[$profile])) { @@ -90,7 +93,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $profile = $this->questionHelper->choose($options, 'Enter a number to choose a container profile:'); } elseif (count($services) === 1) { $service = reset($services); - $profile = $service->container_profile; + $profile = $this->resourcesUtil->containerProfile($service); } else { throw new InvalidArgumentException('The --service or --profile is required.'); } @@ -101,7 +104,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($supportsGuaranteedCPU) { $defaultColumns[] = 'cpu_type'; } - foreach ($containerProfiles[$profile] as $sizeName => $sizeInfo) { + foreach ($containerProfiles[$profile] ?? [] as $sizeName => $sizeInfo) { if (!$supportsGuaranteedCPU && $sizeInfo['cpu_type'] === 'guaranteed') { continue; } @@ -109,7 +112,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'size' => $sizeName, 'cpu' => $this->resourcesUtil->formatCPU($sizeInfo['cpu']), 'memory' => $sizeInfo['memory'], - 'cpu_type' => $sizeInfo['cpu_type'] ?? '', + 'cpu_type' => $sizeInfo['cpu_type'], ]; } diff --git a/legacy/src/Service/QuestionHelper.php b/legacy/src/Service/QuestionHelper.php index 05f2e8b77..a06c9d586 100644 --- a/legacy/src/Service/QuestionHelper.php +++ b/legacy/src/Service/QuestionHelper.php @@ -188,13 +188,15 @@ public function chooseAssoc(array $items, string $text = 'Choose an item:', ?str /** * Ask a simple question which requires input. * + * @template T + * * @param string $questionText * @param mixed|null $default * @param string[] $autoCompleterValues - * @param callable|null $validator + * @param (callable(string): T)|null $validator * @param string $defaultLabel - * @return mixed - * The user's answer. + * @return ($validator is null ? mixed : T) + * The user's answer, as returned by the validator if there is one. */ public function askInput(string $questionText, mixed $default = null, array $autoCompleterValues = [], ?callable $validator = null, string $defaultLabel = 'default: '): mixed { @@ -213,7 +215,7 @@ public function askInput(string $questionText, mixed $default = null, array $aut $question->setAutocompleterValues($autoCompleterValues); } if ($validator !== null) { - $question->setValidator($validator); + $question->setValidator($validator); // @phpstan-ignore argument.type (Symfony passes the answer, a string unless there is no default.) $question->setMaxAttempts(5); } diff --git a/legacy/src/Service/ResourcesUtil.php b/legacy/src/Service/ResourcesUtil.php index dfb6273b3..4ea7ada5d 100644 --- a/legacy/src/Service/ResourcesUtil.php +++ b/legacy/src/Service/ResourcesUtil.php @@ -17,12 +17,26 @@ use Platformsh\Client\Model\Deployment\Worker; use Platformsh\Client\Model\Environment; use Platformsh\Client\Model\Project; +use Platformsh\Client\Model\Project\Settings; use Symfony\Component\Console\Input\InputDefinition; use Symfony\Component\Console\Input\InputInterface; use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\ConsoleOutputInterface; use Symfony\Component\Console\Output\OutputInterface; +/** + * @phpstan-type Sizing array{cpu?: float, memory?: int, disk?: int, profile_size?: string} + * @phpstan-type ContainerResources array{ + * profile_size?: string, + * base_memory?: int, + * memory_ratio?: int, + * minimum: Sizing, + * default: Sizing, + * object_storage?: int, + * } + * @phpstan-type ProfileSize array{cpu: float, memory: int, cpu_type: string} + * @phpstan-type ContainerProfiles array> + */ class ResourcesUtil { private readonly OutputInterface $stdErr; @@ -57,7 +71,7 @@ public function writeSizingApiDisabledError(Project $project): void private function isFixedProject(Project $project): bool { $orgId = $project->getProperty('organization', false, false); - if (!$orgId || !$this->config->getBool('api.organizations')) { + if (!is_string($orgId) || $orgId === '' || !$this->config->getBool('api.organizations')) { return false; } try { @@ -87,7 +101,13 @@ public function allServices(EnvironmentDeployment $deployment): array ksort($workers, SORT_STRING | SORT_FLAG_CASE); ksort($services, SORT_STRING | SORT_FLAG_CASE); ksort($tasks, SORT_STRING | SORT_FLAG_CASE); - return array_merge($webapps, $workers, $services, $tasks); + $all = []; + foreach ([$webapps, $workers, $services, $tasks] as $containers) { + foreach ($containers as $name => $container) { + $all[(string) $name] = $container; + } + } + return $all; } /** @@ -102,7 +122,7 @@ public function supportsDisk(WebApp|Worker|Service|Task $service): bool if ($service instanceof Worker || $service instanceof Task) { return false; } - return isset($service->getProperties()['resources']['minimum']['disk']); + return isset($this->resources($service)['minimum']['disk']); } /** @@ -135,7 +155,7 @@ public function loadNextDeployment(Environment $environment, bool $reset = false * @param array $services * @param InputInterface $input * - * @return WebApp[]|Service[]|Worker[]|Task[]|false + * @return array|false * False on error, or an array of services. */ public function filterServices(array $services, InputInterface $input): array|false @@ -208,26 +228,127 @@ public function filterServices(array $services, InputInterface $input): array|fa } /** - * Returns container profile size info, given service properties. + * Returns a container's resources, typed from its deployment properties. * - * @param array $properties - * The service properties (e.g. from $service->getProperties()). - * @param array $containerProfiles - * The list of container profiles (e.g. from - * $deployment->container_profiles). + * Values that are missing or of an unexpected type are left out. * - * @return array{'cpu': string, 'memory': string}|null + * @return ContainerResources */ - public function sizeInfo(array $properties, array $containerProfiles): ?array + public function resources(WebApp|Worker|Service|Task $service): array { - if (isset($properties['resources']['profile_size'])) { - $size = $properties['resources']['profile_size']; - $profile = $properties['container_profile']; - if (isset($containerProfiles[$profile][$size])) { - return $containerProfiles[$profile][$size]; + $data = $service->getProperties()['resources'] ?? null; + $data = is_array($data) ? $data : []; + $resources = [ + 'minimum' => self::sizing($data['minimum'] ?? null), + 'default' => self::sizing($data['default'] ?? null), + ]; + if (isset($data['profile_size']) && (is_string($data['profile_size']) || is_int($data['profile_size']) || is_float($data['profile_size']))) { + $resources['profile_size'] = (string) $data['profile_size']; + } + if (isset($data['base_memory']) && is_int($data['base_memory'])) { + $resources['base_memory'] = $data['base_memory']; + } + if (isset($data['memory_ratio']) && is_int($data['memory_ratio'])) { + $resources['memory_ratio'] = $data['memory_ratio']; + } + $disk = isset($data['disk']) && is_array($data['disk']) ? $data['disk'] : []; + if (isset($disk['object']) && is_int($disk['object'])) { + $resources['object_storage'] = $disk['object']; + } + + return $resources; + } + + /** + * @return Sizing + */ + private static function sizing(mixed $data): array + { + $data = is_array($data) ? $data : []; + $sizing = []; + if (isset($data['cpu']) && (is_int($data['cpu']) || is_float($data['cpu']))) { + $sizing['cpu'] = (float) $data['cpu']; + } + if (isset($data['memory']) && is_int($data['memory'])) { + $sizing['memory'] = $data['memory']; + } + if (isset($data['disk']) && is_int($data['disk'])) { + $sizing['disk'] = $data['disk']; + } + if (isset($data['profile_size']) && (is_string($data['profile_size']) || is_int($data['profile_size']) || is_float($data['profile_size']))) { + $sizing['profile_size'] = (string) $data['profile_size']; + } + + return $sizing; + } + + /** + * Returns the container profiles of a deployment, typed. + * + * Sizes without a CPU, memory and CPU type are left out. + * + * @return ContainerProfiles + */ + public function containerProfiles(EnvironmentDeployment $deployment): array + { + $data = $deployment->getProperty('container_profiles', false); + $profiles = []; + foreach (is_array($data) ? $data : [] as $profileName => $sizes) { + $profiles[(string) $profileName] = []; + foreach (is_array($sizes) ? $sizes : [] as $sizeName => $size) { + if (is_array($size) && isset($size['cpu'], $size['memory'], $size['cpu_type']) + && (is_int($size['cpu']) || is_float($size['cpu'])) + && is_int($size['memory']) + && is_string($size['cpu_type'])) { + $profiles[(string) $profileName][$sizeName] = ['cpu' => (float) $size['cpu'], 'memory' => $size['memory'], 'cpu_type' => $size['cpu_type']]; + } } } - return null; + + return $profiles; + } + + /** + * Returns a project's build resources. + * + * @return array{cpu: float, memory: int} + */ + public function buildResources(Settings $settings): array + { + $current = $settings['build_resources'] ?? null; + $cpu = is_array($current) ? $current['cpu'] ?? null : null; + $memory = is_array($current) ? $current['memory'] ?? null : null; + if (!(is_int($cpu) || is_float($cpu)) || !is_int($memory)) { + throw new \RuntimeException('The project settings do not include build resources'); + } + + return ['cpu' => (float) $cpu, 'memory' => $memory]; + } + + /** + * Returns a container's profile name: tasks may lack one, and default to BALANCED. + */ + public function containerProfile(WebApp|Worker|Service|Task $service): ?string + { + return $service->container_profile ?? ($service instanceof Task ? 'BALANCED' : null); + } + + /** + * Returns a container's instance count, if it is set. + */ + public function instanceCount(WebApp|Worker|Service|Task $service): ?int + { + $count = $service->getProperties()['instance_count'] ?? null; + return is_int($count) ? $count : null; + } + + /** + * Returns a container's disk size in MB, if it is set. + */ + public function disk(WebApp|Worker|Service|Task $service): ?int + { + $disk = $service->getProperties()['disk'] ?? null; + return is_int($disk) ? $disk : null; } /** diff --git a/legacy/tests/Command/Resources/ResourcesSetTest.php b/legacy/tests/Command/Resources/ResourcesSetTest.php index 3b6b8847f..07a40920a 100644 --- a/legacy/tests/Command/Resources/ResourcesSetTest.php +++ b/legacy/tests/Command/Resources/ResourcesSetTest.php @@ -84,7 +84,7 @@ public function testDiskIsRequiredButUnset(): void if ($disk !== null) { $properties['disk'] = $disk; } - $this->assertSame($expected, $m->invoke($command, $properties), sprintf( + $this->assertSame($expected, $m->invoke($command, WebApp::fromData($properties)), sprintf( 'minimum %s, disk %s', var_export($minimum, true), var_export($disk, true), From 273cd3aeb10fbb2da1d1f08a8d309bf3cbf32316 Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 26 Oct 2026 08:05:40 +0000 Subject: [PATCH 3/5] refactor(legacy): read Config values through typed getters Config's own lookups use has(), getStr() and a private set() instead of indexing its nested array. getStrOrNull() returns an optional string, and getStrList() a list of strings, splitting a string into lines as environment variables set lists that way (e.g. SSH options and host keys). Callers that read strings and lists with get() or getWithDefault() use them. Co-Authored-By: Claude Opus 5.5 --- legacy/phpstan-baseline.neon | 344 +----------------- legacy/src/Application.php | 3 +- .../src/Command/Auth/BrowserLoginCommand.php | 2 +- .../Environment/EnvironmentPushCommand.php | 5 +- .../OrganizationCreateCommand.php | 6 +- legacy/src/Service/Config.php | 165 +++++---- legacy/src/Service/Identifier.php | 2 +- legacy/src/Service/LegacyMigration.php | 2 +- legacy/src/Service/PropertyFormatter.php | 3 +- legacy/src/Service/Ssh.php | 4 +- legacy/src/Service/SshConfig.php | 10 +- legacy/src/Service/State.php | 2 +- legacy/src/Service/TokenConfig.php | 6 +- legacy/tests/ConfigTest.php | 26 ++ 14 files changed, 144 insertions(+), 436 deletions(-) diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 49b291596..4dde293a8 100644 --- a/legacy/phpstan-baseline.neon +++ b/legacy/phpstan-baseline.neon @@ -270,12 +270,6 @@ parameters: count: 1 path: src/Application.php - - - message: '#^Parameter \#1 \$timezoneId of function date_default_timezone_set expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Application.php - - message: '#^Parameter \#1 \$values of method Symfony\\Component\\Console\\Completion\\CompletionSuggestions\:\:suggestValues\(\) expects list\, array\ given\.$#' identifier: argument.type @@ -1050,24 +1044,12 @@ parameters: count: 1 path: src/Command/Environment/EnvironmentPushCommand.php - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 1 - path: src/Command/Environment/EnvironmentPushCommand.php - - message: '#^Parameter \#1 \$id of method Platformsh\\Cli\\Service\\Api\:\:getEnvironment\(\) expects string, mixed given\.$#' identifier: argument.type count: 2 path: src/Command/Environment/EnvironmentPushCommand.php - - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Environment/EnvironmentPushCommand.php - - message: '#^Parameter \#3 \$target of method Platformsh\\Cli\\Command\\Environment\\EnvironmentPushCommand\:\:determineShouldActivate\(\) expects string, mixed given\.$#' identifier: argument.type @@ -1884,12 +1866,6 @@ parameters: count: 1 path: src/Command/Organization/OrganizationCreateCommand.php - - - message: '#^Parameter \#1 \$string of function ucfirst expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Organization/OrganizationCreateCommand.php - - message: '#^Parameter \#1 \$string of method Cocur\\Slugify\\Slugify\:\:slugify\(\) expects string, mixed given\.$#' identifier: argument.type @@ -1929,7 +1905,7 @@ parameters: - message: '#^Possibly invalid array key type mixed\.$#' identifier: offsetAccess.invalidOffset - count: 2 + count: 1 path: src/Command/Organization/OrganizationCreateCommand.php - @@ -4410,276 +4386,24 @@ parameters: count: 1 path: src/Service/Api.php - - - message: '#^Binary operation "\." between mixed and ''\-Cluster'' results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Service/Config.php - - - - message: '#^Binary operation "\." between mixed and ''/''\|''\\\\'' results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Service/Config.php - - - - message: '#^Binary operation "\." between mixed and ''HOME'' results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Service/Config.php - - - - message: '#^Binary operation "\." between mixed and string results in an error\.$#' - identifier: binaryOp.invalid - count: 3 - path: src/Service/Config.php - - - - message: '#^Binary operation "\." between non\-falsy\-string and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''add_to_agent'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''add_to_ssh_agent'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''all_experiments'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''applications_config_file'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''archive_dir''\|''build_dir''\|''dependencies_dir''\|''project_config''\|''shared_dir'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''auto_load_cert'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''auto_load_ssh_cert'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''certifier_url''\|''oauth2_auth_url''\|''oauth2_revoke_url''\|''oauth2_token_url'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''cluster_header'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''console_domain'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''console_url'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''disabled_commands'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''domain_wildcards'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''enable_commands'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''env_prefix'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''experimental_commands'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''header_prefix'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''hidden_commands'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''local_dir'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''oauth2_client_id'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''organization_types'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''prompt'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''session_id'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 3 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''slug'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''ssh_domain_wildcards'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''tmp_sub_dir'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 2 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''wrapped_disabled_commands'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset ''writable_user_dir'' on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot access offset string on mixed\.$#' - identifier: offsetAccess.nonOffsetAccessible - count: 1 - path: src/Service/Config.php - - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 4 - path: src/Service/Config.php - - message: '#^Method Platformsh\\Cli\\Service\\Config\:\:get\(\) should return array\\|bool\|float\|int\|string\|null but returns mixed\.$#' identifier: return.type count: 1 path: src/Service/Config.php - - - message: '#^Method Platformsh\\Cli\\Service\\Config\:\:getProxies\(\) should return array\{https\?\: string, http\?\: string\} but returns array\{\}\|array\{https\?\: string\|false, http\?\: string\|false\}\.$#' - identifier: return.type - count: 1 - path: src/Service/Config.php - - - - message: '#^Method Platformsh\\Cli\\Service\\Config\:\:getSessionId\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Service/Config.php - - - - message: '#^Method Platformsh\\Cli\\Service\\Config\:\:getVersion\(\) should return string but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Service/Config.php - - message: '#^Method Platformsh\\Cli\\Service\\Config\:\:loadConfigFromFile\(\) should return array\ but returns array\\.$#' identifier: return.type count: 1 path: src/Service/Config.php - - - message: '#^Parameter \#1 \$id of method Platformsh\\Cli\\Service\\Config\:\:validateSessionId\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Service/Config.php - - - - message: '#^Parameter \#2 \$haystack of function in_array expects array, mixed given\.$#' - identifier: argument.type - count: 5 - path: src/Service/Config.php - - - - message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Service/Config.php - - message: '#^Property Platformsh\\Cli\\Service\\Config\:\:\$config \(array\\) does not accept array\.$#' identifier: assign.propertyType count: 2 path: src/Service/Config.php - - - message: '#^Property Platformsh\\Cli\\Service\\Config\:\:\$version \(string\|null\) does not accept mixed\.$#' - identifier: assign.propertyType - count: 1 - path: src/Service/Config.php - - message: '#^Method Platformsh\\Cli\\Service\\CountryService\:\:listCountries\(\) should return array\ but returns mixed\.$#' identifier: return.type @@ -4860,24 +4584,12 @@ parameters: count: 1 path: src/Service/Identifier.php - - - message: '#^Parameter \#1 \$callback of function array_map expects \(callable\(mixed\)\: mixed\)\|null, ''preg_quote'' given\.$#' - identifier: argument.type - count: 1 - path: src/Service/Identifier.php - - message: '#^Method Platformsh\\Cli\\Service\\InstallationInfo\:\:cliPath\(\) should return string but returns mixed\.$#' identifier: return.type count: 1 path: src/Service/InstallationInfo.php - - - message: '#^Binary operation "\-" between int\<1, max\> and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Service/LegacyMigration.php - - message: '#^Cannot access offset ''3\.x'' on mixed\.$#' identifier: offsetAccess.nonOffsetAccessible @@ -4914,12 +4626,6 @@ parameters: count: 1 path: src/Service/PropertyFormatter.php - - - message: '#^Parameter \#1 \$timezone of class DateTimeZone constructor expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Service/PropertyFormatter.php - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Service\\PropertyFormatter\:\:formatDate\(\) expects int\|string, mixed given\.$#' identifier: argument.type @@ -5166,48 +4872,18 @@ parameters: count: 1 path: src/Service/Ssh.php - - - message: '#^Method Platformsh\\Cli\\Service\\Ssh\:\:getSshOptions\(\) should return array\ but returns array\\.$#' - identifier: return.type - count: 1 - path: src/Service/Ssh.php - - - - message: '#^Parameter \#1 \$array of function array_unique expects an array of values castable to string, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Service/Ssh.php - - message: '#^Parameter \#3 \$subject of function str_replace expects array\\|string, mixed given\.$#' identifier: argument.type count: 1 path: src/Service/Ssh.php - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 1 - path: src/Service/SshConfig.php - - - - message: '#^PHPDoc tag @var with type array\\|string is not subtype of native type non\-empty\-array\|float\|int\\|int\<1, max\>\|non\-falsy\-string\|true\.$#' - identifier: varTag.nativeType - count: 1 - path: src/Service/SshConfig.php - - message: '#^Parameter \#2 \$array of function implode expects array, mixed given\.$#' identifier: argument.type count: 4 path: src/Service/SshConfig.php - - - message: '#^Parameter \#2 \$array of function implode expects array\, array\ given\.$#' - identifier: argument.type - count: 1 - path: src/Service/SshConfig.php - - message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' identifier: argument.type @@ -5232,30 +4908,12 @@ parameters: count: 1 path: src/Service/SshKey.php - - - message: '#^Binary operation "\." between non\-falsy\-string and mixed results in an error\.$#' - identifier: binaryOp.invalid - count: 1 - path: src/Service/State.php - - message: '#^Property Platformsh\\Cli\\Service\\State\:\:\$state \(array\\) does not accept mixed\.$#' identifier: assign.propertyType count: 1 path: src/Service/State.php - - - message: '#^Cannot cast mixed to string\.$#' - identifier: cast.string - count: 2 - path: src/Service/TokenConfig.php - - - - message: '#^Method Platformsh\\Cli\\Service\\TokenConfig\:\:getAccessToken\(\) should return string\|null but returns mixed\.$#' - identifier: return.type - count: 1 - path: src/Service/TokenConfig.php - - message: '#^Cannot cast mixed to int\.$#' identifier: cast.int diff --git a/legacy/src/Application.php b/legacy/src/Application.php index 72523e26a..3d7ea4110 100644 --- a/legacy/src/Application.php +++ b/legacy/src/Application.php @@ -70,8 +70,7 @@ public function __construct(?Config $config = null) // Use the configured timezone, or fall back to the system timezone. date_default_timezone_set( - $this->config->getWithDefault('application.timezone', null) - ?: TimezoneUtil::getTimezone(), + $this->config->getStrOrNull('application.timezone') ?: TimezoneUtil::getTimezone(), ); // Set the Config service. diff --git a/legacy/src/Command/Auth/BrowserLoginCommand.php b/legacy/src/Command/Auth/BrowserLoginCommand.php index c7b609607..4219835d2 100644 --- a/legacy/src/Command/Auth/BrowserLoginCommand.php +++ b/legacy/src/Command/Auth/BrowserLoginCommand.php @@ -331,7 +331,7 @@ private function getAccessToken(string $authCode, string $codeVerifier, string $ 'headers' => [ 'Content-Type' => 'application/x-www-form-urlencoded', ], - 'auth' => [$this->config->get('api.oauth2_client_id'), ''], + 'auth' => [$this->config->getStr('api.oauth2_client_id'), ''], ]); return (array) Utils::jsonDecode((string) $response->getBody(), true); diff --git a/legacy/src/Command/Environment/EnvironmentPushCommand.php b/legacy/src/Command/Environment/EnvironmentPushCommand.php index f9655d473..c38faf424 100644 --- a/legacy/src/Command/Environment/EnvironmentPushCommand.php +++ b/legacy/src/Command/Environment/EnvironmentPushCommand.php @@ -338,9 +338,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int } // Check the push log for other possible deployment error messages. - $messages = (array) $this->config->get('detection.push_deploy_error_messages'); - foreach ($messages as $message) { - if (str_contains($log, (string) $message)) { + foreach ($this->config->getStrList('detection.push_deploy_error_messages') as $message) { + if (str_contains($log, $message)) { $this->stdErr->writeln(''); $this->stdErr->writeln(\sprintf('The push completed but there was a deployment error ("%s").', $message)); diff --git a/legacy/src/Command/Organization/OrganizationCreateCommand.php b/legacy/src/Command/Organization/OrganizationCreateCommand.php index 5a6e07321..5cc52c4a0 100644 --- a/legacy/src/Command/Organization/OrganizationCreateCommand.php +++ b/legacy/src/Command/Organization/OrganizationCreateCommand.php @@ -47,16 +47,16 @@ private function getForm(): Form $fields['label'] = new Field('Label', [ 'description' => 'The full name of the organization, e.g. "ACME Inc."', ]); - if ($orgTypes = $this->config->get('api.organization_types')) { + if ($orgTypes = $this->config->getStrList('api.organization_types')) { $options = []; - foreach ((array) $orgTypes as $type) { + foreach ($orgTypes as $type) { $options[$type] = ucfirst($type); } $fields['type'] = new OptionsField('Type', [ 'description' => 'The organization type.', 'options' => $options, 'avoidQuestion' => true, - 'default' => $this->config->getWithDefault('api.default_organization_type', key($options)), + 'default' => $this->config->getStrOrNull('api.default_organization_type') ?? key($options), ]); } $fields['name'] = new Field('Name', [ diff --git a/legacy/src/Service/Config.php b/legacy/src/Service/Config.php index 520c65b77..e9f060a24 100644 --- a/legacy/src/Service/Config.php +++ b/legacy/src/Service/Config.php @@ -63,14 +63,14 @@ public function __construct(?array $env = null, ?string $file = null) } catch (\InvalidArgumentException) { throw new \InvalidArgumentException('Invalid session ID in file: ' . $sessionIdFile); } - $this->config['api']['session_id'] = \trim($id); + $this->set('api.session_id', \trim($id)); } } } // Validate the session ID. - if (isset($this->config['api']['session_id'])) { - $this->validateSessionId($this->config['api']['session_id']); + if ($this->has('api.session_id')) { + $this->validateSessionId($this->getStr('api.session_id')); } } @@ -169,6 +169,50 @@ public function getBool(string $name): bool return (bool) $value; } + /** + * Get a string configuration value, or null if it is not defined. + * + * @throws \RuntimeException if the configuration is not a string. + */ + public function getStrOrNull(string $name): ?string + { + return $this->has($name) ? $this->getStr($name) : null; + } + + /** + * Get a list of strings from the configuration, which is empty if the list is not defined. + * + * A string is split into lines, as an environment variable can only set a string. + * + * @throws \RuntimeException if the configuration is not a list of strings. + * + * @return string[] + */ + public function getStrList(string $name): array + { + $value = $this->getWithDefault($name, []); + if (is_string($value)) { + return $value === '' ? [] : explode("\n", $value); + } + $list = []; + foreach (is_array($value) ? $value : [null] as $item) { + if (!is_string($item)) { + throw new \RuntimeException(sprintf('Configuration %s was expected to be a list of strings', $name)); + } + $list[] = $item; + } + + return $list; + } + + /** + * Sets a configuration value, replacing any existing one. + */ + private function set(string $name, mixed $value): void + { + NestedArrayUtil::setNestedArrayValue($this->config, explode('.', $name), $value, true); + } + /** * Get a configuration value, specifying a default if it does not exist. * @@ -202,7 +246,7 @@ public function getHomeDirectory(bool $reset = false): string if (!$reset && isset($this->homeDir)) { return $this->homeDir; } - $prefix = $this->config['application']['env_prefix'] ?? ''; + $prefix = $this->getStrOrNull('application.env_prefix') ?? ''; $envVars = [$prefix . 'HOME', 'HOME', 'USERPROFILE']; foreach ($envVars as $envVar) { $value = getenv($envVar); @@ -255,7 +299,7 @@ private function fs(): Filesystem */ public function getWritableUserDir(): string { - $path = $this->config['application']['writable_user_dir'] ?? $this->getUserConfigDir(false); + $path = $this->getStrOrNull('application.writable_user_dir') ?? $this->getUserConfigDir(false); $configDir = $this->getHomeDirectory() . DIRECTORY_SEPARATOR . $path; // If the directory is not writable (e.g. if we are on a Platform.sh @@ -282,7 +326,7 @@ public function getSessionDir(bool $subDir = false): string */ public function getSessionId(): string { - return $this->getWithDefault('api.session_id', 'default'); + return $this->getStrOrNull('api.session_id') ?? 'default'; } /** @@ -300,7 +344,7 @@ public function getSessionIdSlug(string $prefix = 'sess-cli-'): string */ public function setSessionId(string $id, bool $persist = false): void { - $this->config['api']['session_id'] = $id; + $this->set('api.session_id', $id); if ($persist) { $filename = $this->getSessionIdFile(); if ($id === 'default') { @@ -450,17 +494,17 @@ private function applyEnvironmentOverrides(): void // Special case: replace the list ssh.domain_wildcards with the value of {PREFIX}SSH_DOMAIN_WILDCARD. if (($value = $this->getEnv('SSH_DOMAIN_WILDCARD')) !== false) { - $this->config['ssh']['domain_wildcards'] = [$value]; + $this->set('ssh.domain_wildcards', [$value]); } // Special case: {PREFIX}NO_LEGACY_WARNING disables the migration prompt. if ($this->getEnv('NO_LEGACY_WARNING')) { - $this->config['migrate']['prompt'] = false; + $this->set('migrate.prompt', false); } // Special case: replace the list api.organization_types with the (split) value of {PREFIX}API_ORGANIZATION_TYPES. if (($value = $this->getEnv('API_ORGANIZATION_TYPES')) !== false) { - $this->config['api']['organization_types'] = $value === '' ? [] : \preg_split('/[,\s]+/', $value); + $this->set('api.organization_types', $value === '' ? [] : \preg_split('/[,\s]+/', $value)); } } @@ -477,7 +521,7 @@ private function applyEnvironmentOverrides(): void */ private function getEnv(string $name, bool $addPrefix = true): string|false { - $prefix = $addPrefix && isset($this->config['application']['env_prefix']) ? $this->config['application']['env_prefix'] : ''; + $prefix = $addPrefix ? $this->getStrOrNull('application.env_prefix') ?? '' : ''; if (array_key_exists($prefix . $name, $this->env)) { return $this->env[$prefix . $name]; } @@ -502,7 +546,7 @@ private function applyUserConfigOverrides(): void */ public function isExperimentEnabled(string $name): bool { - return !empty($this->config['experimental']['all_experiments']) || !empty($this->config['experimental'][$name]); + return !empty($this->getWithDefault('experimental.all_experiments', false)) || !empty($this->getWithDefault('experimental.' . $name, false)); } /** @@ -510,8 +554,7 @@ public function isExperimentEnabled(string $name): bool */ public function isCommandHidden(string $name): bool { - return (!empty($this->config['application']['hidden_commands']) - && in_array($name, $this->config['application']['hidden_commands'])); + return in_array($name, $this->getStrList('application.hidden_commands')); } /** @@ -519,22 +562,15 @@ public function isCommandHidden(string $name): bool */ public function isCommandEnabled(string $name): bool { - if (!empty($this->config['application']['disabled_commands']) - && in_array($name, $this->config['application']['disabled_commands'])) { + if (in_array($name, $this->getStrList('application.disabled_commands'))) { return false; } - if ($this->isWrapped() - && !empty($this->config['application']['wrapped_disabled_commands']) - && in_array($name, $this->config['application']['wrapped_disabled_commands'])) { + if ($this->isWrapped() && in_array($name, $this->getStrList('application.wrapped_disabled_commands'))) { return false; } - if (!empty($this->config['application']['experimental_commands']) - && in_array($name, $this->config['application']['experimental_commands'])) { - return !empty($this->config['experimental']['all_experiments']) - || ( - !empty($this->config['experimental']['enable_commands']) - && in_array($name, $this->config['experimental']['enable_commands']) - ); + if (in_array($name, $this->getStrList('application.experimental_commands'))) { + return !empty($this->getWithDefault('experimental.all_experiments', false)) + || in_array($name, $this->getStrList('experimental.enable_commands')); } return true; @@ -548,8 +584,8 @@ public function getVersion(): string if (isset($this->version)) { return $this->version; } - $version = $this->getWithDefault('application.version', '@version-placeholder@'); - if (str_starts_with((string) $version, '@') && str_ends_with((string) $version, '@') && !str_starts_with(CLI_ROOT, 'phar://')) { + $version = $this->getStrOrNull('application.version') ?? '@version-placeholder@'; + if (str_starts_with($version, '@') && str_ends_with($version, '@') && !str_starts_with(CLI_ROOT, 'phar://')) { // Silently try getting the version from Git. $tag = (new Shell())->execute(['git', 'describe', '--tags'], CLI_ROOT); if (is_string($tag) && str_starts_with($tag, 'v')) { @@ -568,10 +604,7 @@ public function getVersion(): string */ public function getUserAgent(): string { - $template = $this->getWithDefault( - 'api.user_agent', - '{APP_NAME_DASH}/{VERSION} ({UNAME_S}; {UNAME_R}; PHP {PHP_VERSION})', - ); + $template = $this->getStrOrNull('api.user_agent') ?? '{APP_NAME_DASH}/{VERSION} ({UNAME_S}; {UNAME_R}; PHP {PHP_VERSION})'; /** @var array $replacements */ $replacements = [ '{APP_NAME_DASH}' => \str_replace(' ', '-', $this->getStr('application.name')), @@ -593,12 +626,12 @@ public function getUserAgent(): string public function getProxies(): array { $proxies = []; - if (\getenv('https_proxy') !== false) { - $proxies['https'] = \getenv('https_proxy'); + if (($https = \getenv('https_proxy')) !== false) { + $proxies['https'] = $https; } // An environment variable prefixed by 'http_' cannot be trusted in a non-CLI (web) context. - if (PHP_SAPI === 'cli' && \getenv('http_proxy') !== false) { - $proxies['http'] = \getenv('http_proxy'); + if (PHP_SAPI === 'cli' && ($http = \getenv('http_proxy')) !== false) { + $proxies['http'] = $http; } return $proxies; } @@ -730,43 +763,44 @@ private function applyDynamicDefaults(): void $this->applyUrlDefaults(); $this->applyLocalDirectoryDefaults(); - if (!isset($this->config['application']['slug'])) { - $this->config['application']['slug'] = preg_replace('/[^a-z0-9-]+/', '-', str_replace(['.', ' '], ['', '-'], strtolower($this->getStr('application.name')))); + if (!$this->has('application.slug')) { + $this->set('application.slug', preg_replace('/[^a-z0-9-]+/', '-', str_replace(['.', ' '], ['', '-'], strtolower($this->getStr('application.name'))))); } - if (!isset($this->config['application']['tmp_sub_dir'])) { - $this->config['application']['tmp_sub_dir'] = $this->getStr('application.slug') . '-tmp'; + if (!$this->has('application.tmp_sub_dir')) { + $this->set('application.tmp_sub_dir', $this->getStr('application.slug') . '-tmp'); } - if (!isset($this->config['api']['oauth2_client_id'])) { - $this->config['api']['oauth2_client_id'] = $this->getStr('application.slug'); + if (!$this->has('api.oauth2_client_id')) { + $this->set('api.oauth2_client_id', $this->getStr('application.slug')); } - if (!isset($this->config['detection']['console_domain']) && isset($this->config['service']['console_url'])) { - $consoleDomain = parse_url((string) $this->config['service']['console_url'], PHP_URL_HOST); + if (!$this->has('detection.console_domain') && $this->has('service.console_url')) { + $consoleDomain = parse_url($this->getStr('service.console_url'), PHP_URL_HOST); if ($consoleDomain !== false) { - $this->config['detection']['console_domain'] = $consoleDomain; + $this->set('detection.console_domain', $consoleDomain); } } - if (!isset($this->config['service']['applications_config_file'])) { - $this->config['service']['applications_config_file'] = $this->getStr('service.project_config_dir') . '/applications.yaml'; + if (!$this->has('service.applications_config_file')) { + $this->set('service.applications_config_file', $this->getStr('service.project_config_dir') . '/applications.yaml'); } // Migrate renamed config keys. - if (isset($this->config['api']['add_to_ssh_agent']) && !isset($this->config['ssh']['add_to_agent'])) { - $this->config['ssh']['add_to_agent'] = $this->config['api']['add_to_ssh_agent']; - } - if (isset($this->config['api']['auto_load_ssh_cert']) && !isset($this->config['ssh']['auto_load_cert'])) { - $this->config['ssh']['auto_load_cert'] = $this->config['api']['auto_load_ssh_cert']; - } - if (isset($this->config['api']['ssh_domain_wildcards']) && !isset($this->config['ssh']['domain_wildcards'])) { - $this->config['ssh']['domain_wildcards'] = $this->config['api']['ssh_domain_wildcards']; + $renamed = [ + 'api.add_to_ssh_agent' => 'ssh.add_to_agent', + 'api.auto_load_ssh_cert' => 'ssh.auto_load_cert', + 'api.ssh_domain_wildcards' => 'ssh.domain_wildcards', + ]; + foreach ($renamed as $old => $new) { + if ($this->has($old) && !$this->has($new)) { + $this->set($new, $this->get($old)); + } } - if (isset($this->config['service']['header_prefix']) && !isset($this->config['detection']['cluster_header'])) { - $this->config['detection']['cluster_header'] = $this->config['service']['header_prefix'] . '-Cluster'; + if ($this->has('service.header_prefix') && !$this->has('detection.cluster_header')) { + $this->set('detection.cluster_header', $this->getStr('service.header_prefix') . '-Cluster'); } } private function applyUrlDefaults(): void { - $authUrl = $this->getWithDefault('api.auth_url', ''); + $authUrl = $this->getStrOrNull('api.auth_url') ?? ''; if ($authUrl === '') { return; } @@ -777,19 +811,18 @@ private function applyUrlDefaults(): void 'certifier_url' => '', ]; foreach ($defaultsUnderAuthUrl as $apiSubKey => $path) { - if (!isset($this->config['api'][$apiSubKey])) { - $this->config['api'][$apiSubKey] = rtrim((string) $authUrl, '/') . $path; + if (!$this->has('api.' . $apiSubKey)) { + $this->set('api.' . $apiSubKey, rtrim($authUrl, '/') . $path); } } } private function applyLocalDirectoryDefaults(): void { - if (isset($this->config['local']['local_dir'])) { - $localDir = $this->config['local']['local_dir']; - } else { + $localDir = $this->getStrOrNull('local.local_dir'); + if ($localDir === null) { $localDir = $this->getStr('service.project_config_dir') . DIRECTORY_SEPARATOR . 'local'; - $this->config['local']['local_dir'] = $localDir; + $this->set('local.local_dir', $localDir); } $defaultsUnderLocalDir = [ 'archive_dir' => 'build-archives', @@ -799,8 +832,8 @@ private function applyLocalDirectoryDefaults(): void 'shared_dir' => 'shared', ]; foreach ($defaultsUnderLocalDir as $localSubKey => $subPath) { - if (!isset($this->config['local'][$localSubKey])) { - $this->config['local'][$localSubKey] = $localDir . DIRECTORY_SEPARATOR . $subPath; + if (!$this->has('local.' . $localSubKey)) { + $this->set('local.' . $localSubKey, $localDir . DIRECTORY_SEPARATOR . $subPath); } } } diff --git a/legacy/src/Service/Identifier.php b/legacy/src/Service/Identifier.php index 355c2ce4d..6339e063e 100644 --- a/legacy/src/Service/Identifier.php +++ b/legacy/src/Service/Identifier.php @@ -79,7 +79,7 @@ private function parseProjectId(string $url): array $path = $urlParts['path'] ?? ''; $fragment = $urlParts['fragment'] ?? ''; - $site_domains_pattern = '(' . implode('|', array_map('preg_quote', (array) $this->config->get('detection.site_domains'))) . ')'; + $site_domains_pattern = '(' . implode('|', array_map(fn(string $domain): string => preg_quote($domain), $this->config->getStrList('detection.site_domains'))) . ')'; $site_pattern = '/\-\w+\.[a-z]{2}(\-[0-9])?\.' . $site_domains_pattern . '$/'; if (preg_match($site_pattern, $host)) { diff --git a/legacy/src/Service/LegacyMigration.php b/legacy/src/Service/LegacyMigration.php index ad2f705a2..b40126f72 100644 --- a/legacy/src/Service/LegacyMigration.php +++ b/legacy/src/Service/LegacyMigration.php @@ -163,7 +163,7 @@ private function checkMigrateToNewCLI(): void // Prompt the user to migrate at most once every 24 hours. $now = time(); - $embargoTime = $now - $this->config->getWithDefault('migrate.prompt_interval', 60 * 60 * 24); + $embargoTime = $now - ($this->config->has('migrate.prompt_interval') ? $this->config->getInt('migrate.prompt_interval') : 60 * 60 * 24); if ($this->state->get('migrate.last_prompted') > $embargoTime) { return; } diff --git a/legacy/src/Service/PropertyFormatter.php b/legacy/src/Service/PropertyFormatter.php index af0b53672..1b96bf75e 100644 --- a/legacy/src/Service/PropertyFormatter.php +++ b/legacy/src/Service/PropertyFormatter.php @@ -130,8 +130,7 @@ public function formatDate(int|string $value): string $dateTime = new \DateTime($value); } $dateTime->setTimezone(new \DateTimeZone( - $this->config->getWithDefault('application.timezone', null) - ?: TimezoneUtil::getTimezone(), + $this->config->getStrOrNull('application.timezone') ?: TimezoneUtil::getTimezone(), )); return $dateTime->format($this->dateFormat()); diff --git a/legacy/src/Service/Ssh.php b/legacy/src/Service/Ssh.php index a88020170..3e196347d 100644 --- a/legacy/src/Service/Ssh.php +++ b/legacy/src/Service/Ssh.php @@ -158,9 +158,7 @@ private function getSshOptions(?bool $hostIsInternal): array } } - if ($configuredOptions = $this->config->get('ssh.options')) { - $options = array_merge($options, is_array($configuredOptions) ? $configuredOptions : explode("\n", (string) $configuredOptions)); - } + $options = array_merge($options, $this->config->getStrList('ssh.options')); // Avoid repeating options. return array_unique($options); diff --git a/legacy/src/Service/SshConfig.php b/legacy/src/Service/SshConfig.php index 3e131a422..f3a1abf6a 100644 --- a/legacy/src/Service/SshConfig.php +++ b/legacy/src/Service/SshConfig.php @@ -40,10 +40,7 @@ public function configureHostKeys(): ?string return null; } } - if ($additionalKeys = $this->config->getWithDefault('ssh.host_keys', [])) { - if (!is_array($additionalKeys)) { - $additionalKeys = explode("\n", (string) $additionalKeys); - } + if ($additionalKeys = $this->config->getStrList('ssh.host_keys')) { $hostKeys = rtrim($hostKeys, "\n") . "\n" . implode("\n", $additionalKeys); } if (empty($hostKeys)) { @@ -147,11 +144,10 @@ public function configureSessionSsh(): bool $includerFilename = $this->getCliSshDir() . DIRECTORY_SEPARATOR . 'session.config'; // Add other configured options. - if ($configuredOptions = $this->config->get('ssh.options')) { - /** @var string[]|string $configuredOptions */ + if ($configuredOptions = $this->config->getStrList('ssh.options')) { $lines[] = ''; $lines[] = "# Other options from the CLI's configuration."; - $lines = array_merge($lines, is_array($configuredOptions) ? $configuredOptions : explode("\n", $configuredOptions)); + $lines = array_merge($lines, $configuredOptions); } $this->writeSshIncludeFile($sessionSpecificFilename, $lines); diff --git a/legacy/src/Service/State.php b/legacy/src/Service/State.php index 26f5a5339..e11f333fd 100644 --- a/legacy/src/Service/State.php +++ b/legacy/src/Service/State.php @@ -80,6 +80,6 @@ protected function load(): void */ protected function getFilename(): string { - return $this->config->getWritableUserDir() . DIRECTORY_SEPARATOR . $this->config->getWithDefault('application.user_state_file', 'state.json'); + return $this->config->getWritableUserDir() . DIRECTORY_SEPARATOR . ($this->config->getStrOrNull('application.user_state_file') ?? 'state.json'); } } diff --git a/legacy/src/Service/TokenConfig.php b/legacy/src/Service/TokenConfig.php index b98bb2280..a7b644975 100644 --- a/legacy/src/Service/TokenConfig.php +++ b/legacy/src/Service/TokenConfig.php @@ -28,12 +28,12 @@ public function storage(): StorageInterface */ public function getApiToken(bool $includeStored = true): ?string { - $token = (string) $this->config->getWithDefault('api.token', ''); + $token = $this->config->getStrOrNull('api.token') ?? ''; if ($token !== '') { return $token; } - $file = (string) $this->config->getWithDefault('api.token_file', ''); + $file = $this->config->getStrOrNull('api.token_file') ?? ''; if ($file !== '') { $token = $this->loadTokenFromFile($file); if ($token !== '') { @@ -53,7 +53,7 @@ public function getApiToken(bool $includeStored = true): ?string public function getAccessToken(): ?string { - return $this->config->getWithDefault('api.access_token', null); + return $this->config->getStrOrNull('api.access_token'); } /** diff --git a/legacy/tests/ConfigTest.php b/legacy/tests/ConfigTest.php index 650472110..1be74f920 100644 --- a/legacy/tests/ConfigTest.php +++ b/legacy/tests/ConfigTest.php @@ -57,6 +57,32 @@ public function testEnvironmentOverrides(): void $this->assertEquals('Overridden application name', $config->get('application.name')); } + public function testGetStrList(): void + { + $config = (new Config([], $this->configFile))->withOverrides([ + 'test.list' => ['a', 'b'], + 'test.lines' => "a\nb", + 'test.empty' => '', + 'test.null' => null, + 'test.mixed' => ['a', 1], + ]); + $this->assertSame(['a', 'b'], $config->getStrList('test.list')); + $this->assertSame(['a', 'b'], $config->getStrList('test.lines')); + $this->assertSame([], $config->getStrList('test.empty')); + $this->assertSame([], $config->getStrList('test.null')); + $this->assertSame([], $config->getStrList('test.undefined')); + $this->expectException(\RuntimeException::class); + $config->getStrList('test.mixed'); + } + + public function testGetStrOrNull(): void + { + $config = (new Config([], $this->configFile))->withOverrides(['test.str' => 'a', 'test.null' => null]); + $this->assertSame('a', $config->getStrOrNull('test.str')); + $this->assertNull($config->getStrOrNull('test.null')); + $this->assertNull($config->getStrOrNull('test.undefined')); + } + /** * Test that selected user config can override initial config. */ From f06ac4afa82c47579b0dbd409d88b17b21fadf1f Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 26 Oct 2026 08:21:56 +0000 Subject: [PATCH 4/5] fix(autoscaling:set): ask again for a value without a default When the defaults and the service's settings both omit a value, its question has no default, and Symfony passes an empty answer to the validator as null. That was a TypeError, which ended the command. The answer is now rejected, so the question is asked again. Co-Authored-By: Claude Opus 5.5 --- integration-tests/autoscaling_enabled_test.go | 28 +++++++++-------- integration-tests/autoscaling_get_test.go | 30 +++++++++++++++++++ legacy/phpstan-baseline.neon | 12 -------- .../AutoscalingSettingsSetCommand.php | 29 +++++++++++++----- 4 files changed, 66 insertions(+), 33 deletions(-) diff --git a/integration-tests/autoscaling_enabled_test.go b/integration-tests/autoscaling_enabled_test.go index 9519237c5..644f91d8d 100644 --- a/integration-tests/autoscaling_enabled_test.go +++ b/integration-tests/autoscaling_enabled_test.go @@ -80,6 +80,20 @@ func TestAutoscalingSettingsSetEnabled(t *testing.T) { // setUpAutoscaling configures a project with an "app" and the given services' // autoscaling settings. func setUpAutoscaling(apiHandler *mockapi.Handler, services map[string]any) (projectID string) { + trigger := map[string]any{ + "enabled": false, + "up": map[string]any{"threshold": 80, "duration": 60}, + "down": map[string]any{"threshold": 20, "duration": 600}, + } + return setUpAutoscalingWithDefaults(apiHandler, map[string]any{ + "triggers": map[string]any{"cpu": trigger, "memory": trigger}, + "scale_cooldown": map[string]any{"up": 300, "down": 300}, + "instances": map[string]any{"min": 1, "max": 10}, + }, services) +} + +// setUpAutoscalingWithDefaults is setUpAutoscaling with the given autoscaling defaults. +func setUpAutoscalingWithDefaults(apiHandler *mockapi.Handler, defaults, services map[string]any) (projectID string) { projectID = mockapi.ProjectID() apiHandler.SetProjects([]*mockapi.Project{{ ID: projectID, @@ -117,19 +131,7 @@ func setUpAutoscaling(apiHandler *mockapi.Handler, services map[string]any) (pro }) }) - trigger := map[string]any{ - "enabled": false, - "up": map[string]any{"threshold": 80, "duration": 60}, - "down": map[string]any{"threshold": 20, "duration": 600}, - } - main.SetAutoscalingSettings(map[string]any{ - "defaults": map[string]any{ - "triggers": map[string]any{"cpu": trigger, "memory": trigger}, - "scale_cooldown": map[string]any{"up": 300, "down": 300}, - "instances": map[string]any{"min": 1, "max": 10}, - }, - "services": services, - }) + main.SetAutoscalingSettings(map[string]any{"defaults": defaults, "services": services}) return projectID } diff --git a/integration-tests/autoscaling_get_test.go b/integration-tests/autoscaling_get_test.go index 2e8d854d0..88d95b2de 100644 --- a/integration-tests/autoscaling_get_test.go +++ b/integration-tests/autoscaling_get_test.go @@ -2,6 +2,7 @@ package tests import ( "net/http/httptest" + "strings" "testing" "github.com/stretchr/testify/assert" @@ -72,3 +73,32 @@ func TestAutoscalingSettingsSetPartial(t *testing.T) { assert.Contains(t, stderr, "Cooldown: 5m") assert.Contains(t, stderr, "Min: 2") } + +// TestAutoscalingSettingsSetInteractivePartialDefaults checks the interactive form when the defaults omit a value. +func TestAutoscalingSettingsSetInteractivePartialDefaults(t *testing.T) { + t.Parallel() + apiHandler := mockapi.NewHandler(t) + projectID := setUpAutoscalingWithDefaults(apiHandler, map[string]any{ + "triggers": map[string]any{"cpu": map[string]any{ + "up": map[string]any{"threshold": 80, "duration": 60}, + "down": map[string]any{"threshold": 20, "duration": 600}, + }}, + "instances": map[string]any{"min": 1}, + }, map[string]any{}) + authServer := mockapi.NewAuthServer(t) + defer authServer.Close() + apiServer := httptest.NewServer(apiHandler) + defer apiServer.Close() + f := newCommandFactory(t, apiServer.URL, authServer.URL) + + // Accept the defaults for the metric, and for each scaling direction's threshold and duration + // (the only app is chosen without a question). The cooldowns and the maximum instance count + // have no default: an empty answer is rejected and the question is asked again. + _, stderr, err := f.RunInteractive("\n\n\n\n5m\n\n\n\n5m\n\n\n3\n", + "autoscaling:set", "-p", projectID, "-e", "main", "--dry-run") + require.NoError(t, err, stderr) + assert.NotContains(t, stderr, "TypeError") + assert.Equal(t, 3, strings.Count(stderr, "A value is required.")) + assert.Contains(t, stderr, "Cooldown: 5m") + assert.Contains(t, stderr, "Max: 3") +} diff --git a/legacy/phpstan-baseline.neon b/legacy/phpstan-baseline.neon index 4dde293a8..b2d773029 100644 --- a/legacy/phpstan-baseline.neon +++ b/legacy/phpstan-baseline.neon @@ -450,18 +450,6 @@ parameters: count: 4 path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:validateInstanceCount\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 2 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - - - message: '#^Parameter \#1 \$value of method Platformsh\\Cli\\Command\\Autoscaling\\AutoscalingSettingsSetCommand\:\:validateThreshold\(\) expects string, mixed given\.$#' - identifier: argument.type - count: 1 - path: src/Command/Autoscaling/AutoscalingSettingsSetCommand.php - - message: '#^Parameter \#2 \$callback of function array_filter expects \(callable\(Platformsh\\Client\\Model\\Deployment\\Service\|Platformsh\\Client\\Model\\Deployment\\WebApp\|Platformsh\\Client\\Model\\Deployment\\Worker\)\: bool\)\|null, Closure\(mixed\)\: mixed given\.$#' identifier: argument.type diff --git a/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php b/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php index 8c3539aed..1c1a8d214 100644 --- a/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php +++ b/legacy/src/Command/Autoscaling/AutoscalingSettingsSetCommand.php @@ -364,8 +364,8 @@ private function handleScalingSettings( 'Enter the threshold (%)', $currentServiceSettings['triggers'][$metric][$direction]['threshold'] ?? null, $defaults['triggers'][$metric][$direction]['threshold'] ?? null, - function ($value) { - return $this->validateThreshold($value); + function (?string $value) { + return $this->validateThreshold($this->requireAnswer($value)); }, $service, 'threshold-' . $direction, @@ -413,8 +413,8 @@ private function handleInstanceSettings( 'Enter the minimum number of instances', $currentServiceSettings['instances']['min'] ?? null, 1, - function ($value) use ($instanceLimit) { - return $this->validateInstanceCount($value, $instanceLimit); + function (?string $value) use ($instanceLimit) { + return $this->validateInstanceCount($this->requireAnswer($value), $instanceLimit); }, $service, 'instances-min', @@ -426,8 +426,8 @@ function ($value) use ($instanceLimit) { 'Enter the maximum number of instances', $currentServiceSettings['instances']['max'] ?? null, $instanceLimit, - function ($value) use ($instanceLimit) { - return $this->validateInstanceCount($value, $instanceLimit); + function (?string $value) use ($instanceLimit) { + return $this->validateInstanceCount($this->requireAnswer($value), $instanceLimit); }, $service, 'instances-max', @@ -502,8 +502,8 @@ private function askForDurationSetting( if ($currentValue === null) { $choices = array_keys(self::$validDurations); $default = $existingValue ?? $defaultValue; - $newValue = $this->questionHelper->askInput($prompt, $default !== null ? $this->formatDuration($default) : null, $choices, function ($v) { - return $this->validateDuration($v); + $newValue = $this->questionHelper->askInput($prompt, $default !== null ? $this->formatDuration($default) : null, $choices, function (?string $v) { + return $this->validateDuration($this->requireAnswer($v)); }); $this->stdErr->writeln(''); @@ -845,6 +845,19 @@ protected function validateBoolean(float|int|string|bool $value): bool }; } + /** + * Rejects an empty answer to a question without a default, which Symfony passes as null. + * + * @throws InvalidArgumentException + */ + private function requireAnswer(?string $value): string + { + if ($value === null) { + throw new InvalidArgumentException('A value is required.'); + } + return $value; + } + /** * Validates a given threshold. * From 3fe2e4046ff5c03254203953f7efc6acfcd653ae Mon Sep 17 00:00:00 2001 From: Patrick Dawkins Date: Mon, 26 Oct 2026 17:03:30 +0000 Subject: [PATCH 5/5] fix(legacy): show a trigger's unset state, and read partial build resources - autoscaling:get carried a trigger's enabled state into the rows of the next trigger when that one didn't set it. - resources:build:get and resources:build:set failed when the project's build resources settings omitted a value or had an unexpected type. A missing value is now shown as empty, and can be set. Co-Authored-By: Claude Opus 5.5 --- integration-tests/autoscaling_get_test.go | 3 +++ integration-tests/resources_build_test.go | 24 ++++++++++++++++++- .../AutoscalingSettingsGetCommand.php | 4 +--- .../Build/BuildResourcesGetCommand.php | 4 ++-- .../Build/BuildResourcesSetCommand.php | 24 ++++++++++--------- legacy/src/Service/ResourcesUtil.php | 12 +++++----- 6 files changed, 48 insertions(+), 23 deletions(-) diff --git a/integration-tests/autoscaling_get_test.go b/integration-tests/autoscaling_get_test.go index 88d95b2de..89bf5d9d2 100644 --- a/integration-tests/autoscaling_get_test.go +++ b/integration-tests/autoscaling_get_test.go @@ -23,6 +23,8 @@ func TestAutoscalingSettingsGetDirections(t *testing.T) { "up": map[string]any{"threshold": 80, "duration": 60}, "down": map[string]any{"threshold": 20, "duration": 300}, }, + // The memory trigger doesn't say whether it is enabled. + "memory": map[string]any{"up": map[string]any{"threshold": 70, "duration": 60}}, }, "instances": map[string]any{"min": 1, "max": 3}, "scale_cooldown": map[string]any{"up": 300, "down": 600}, @@ -41,6 +43,7 @@ func TestAutoscalingSettingsGetDirections(t *testing.T) { App or service,Metric,Direction,Threshold (%),Duration (s),Enabled,Cooldown (s),Minimum instances,Maximum instances app,cpu,up,80.0%,60,true,300,1,3 app,cpu,down,20.0%,300,true,600,1,3 +app,memory,up,70.0%,60,,300,1,3 `, stdout) } diff --git a/integration-tests/resources_build_test.go b/integration-tests/resources_build_test.go index 46dd38fd9..1790e6b8a 100644 --- a/integration-tests/resources_build_test.go +++ b/integration-tests/resources_build_test.go @@ -13,6 +13,13 @@ import ( // setUpBuildResources serves a project with build resources of 1 CPU and 2048 MB, and maximums of 4 CPU and 8192 MB. func setUpBuildResources(t *testing.T) (apiHandler *mockapi.Handler, f *cmdFactory, projectID string) { + return setUpBuildResourcesWith(t, map[string]any{"cpu": 1, "memory": 2048}) +} + +// setUpBuildResourcesWith is setUpBuildResources with the given build resources settings. +func setUpBuildResourcesWith(t *testing.T, buildResources map[string]any) ( + apiHandler *mockapi.Handler, f *cmdFactory, projectID string, +) { apiHandler = mockapi.NewHandler(t) projectID = mockapi.ProjectID() apiHandler.SetProjects([]*mockapi.Project{{ @@ -24,7 +31,7 @@ func setUpBuildResources(t *testing.T) (apiHandler *mockapi.Handler, f *cmdFacto DefaultBranch: "main", Settings: map[string]any{ "sizing_api_enabled": true, - "build_resources": map[string]any{"cpu": 1, "memory": 2048}, + "build_resources": buildResources, }, }}) apiHandler.SetEnvironments([]*mockapi.Environment{makeEnv(projectID, "main", "production", "active", nil)}) @@ -118,3 +125,18 @@ func TestBuildResourcesSet(t *testing.T) { assert.Empty(t, apiHandler.ProjectSettingsPatches(projectID)) }) } + +// TestBuildResourcesPartial checks the build resources commands when the settings omit a value. +func TestBuildResourcesPartial(t *testing.T) { + t.Parallel() + apiHandler, f, projectID := setUpBuildResourcesWith(t, map[string]any{"cpu": 1}) + + assertTrimmed(t, "CPU\tMemory (MB)\n1\t", f.Run("resources:build:get", "-p", projectID, "--format", "tsv")) + + _, stderr, err := f.RunCombinedOutput("resources:build:set", "-p", projectID, "--memory", "1024") + require.NoError(t, err, stderr) + assert.Contains(t, stderr, "Memory: 1024 MB") + patches := apiHandler.ProjectSettingsPatches(projectID) + require.Len(t, patches, 1) + assert.Equal(t, map[string]any{"build_resources": map[string]any{"memory": 1024.0}}, patches[0]) +} diff --git a/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php b/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php index fc4b8b173..7359c463a 100644 --- a/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php +++ b/legacy/src/Command/Autoscaling/AutoscalingSettingsGetCommand.php @@ -124,9 +124,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int foreach ($settings['triggers'] as $metric => $trigger) { $row['metric'] = $metric; - if (isset($trigger['enabled'])) { - $row['enabled'] = $this->propertyFormatter->format($trigger['enabled'], 'enabled'); - } + $row['enabled'] = isset($trigger['enabled']) ? $this->propertyFormatter->format($trigger['enabled'], 'enabled') : $empty; foreach (['up', 'down'] as $direction) { if (!isset($trigger[$direction])) { continue; diff --git a/legacy/src/Command/Resources/Build/BuildResourcesGetCommand.php b/legacy/src/Command/Resources/Build/BuildResourcesGetCommand.php index 512d07484..b564fcdf6 100644 --- a/legacy/src/Command/Resources/Build/BuildResourcesGetCommand.php +++ b/legacy/src/Command/Resources/Build/BuildResourcesGetCommand.php @@ -58,8 +58,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int $rows = [ [ - 'cpu' => $current['cpu'], - 'memory' => $current['memory'], + 'cpu' => $current['cpu'] ?? '', + 'memory' => $current['memory'] ?? '', ], ]; diff --git a/legacy/src/Command/Resources/Build/BuildResourcesSetCommand.php b/legacy/src/Command/Resources/Build/BuildResourcesSetCommand.php index 43b5a6030..c7f24c505 100644 --- a/legacy/src/Command/Resources/Build/BuildResourcesSetCommand.php +++ b/legacy/src/Command/Resources/Build/BuildResourcesSetCommand.php @@ -104,7 +104,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($cpuOption === null && $memoryOption === null) { $cpuOption = $this->questionHelper->askInput( 'CPU size', - $this->resourcesUtil->formatCPU($current['cpu']), + $current['cpu'] !== null ? $this->resourcesUtil->formatCPU($current['cpu']) : null, [], $validateCpu, 'current: ', @@ -154,20 +154,22 @@ protected function execute(InputInterface $input, OutputInterface $output): int * Summarizes all the changes that would be made. * * @param array{cpu?: float, memory?: int} $updates - * @param array{cpu: float, memory: int} $current + * @param array{cpu: float|null, memory: int|null} $current * @return void */ private function summarizeChanges(array $updates, array $current): void { $this->stdErr->writeln('Summary of changes:'); - $this->stdErr->writeln(' CPU: ' . $this->resourcesUtil->formatChange( - $this->resourcesUtil->formatCPU($current['cpu']), - $this->resourcesUtil->formatCPU($updates['cpu'] ?? $current['cpu']), - )); - $this->stdErr->writeln(' Memory: ' . $this->resourcesUtil->formatChange( - $current['memory'], - $updates['memory'] ?? $current['memory'], - ' MB', - )); + $cpu = $updates['cpu'] ?? $current['cpu']; + if ($cpu !== null) { + $this->stdErr->writeln(' CPU: ' . $this->resourcesUtil->formatChange( + $current['cpu'] !== null ? $this->resourcesUtil->formatCPU($current['cpu']) : null, + $this->resourcesUtil->formatCPU($cpu), + )); + } + $memory = $updates['memory'] ?? $current['memory']; + if ($memory !== null) { + $this->stdErr->writeln(' Memory: ' . $this->resourcesUtil->formatChange($current['memory'], $memory, ' MB')); + } } } diff --git a/legacy/src/Service/ResourcesUtil.php b/legacy/src/Service/ResourcesUtil.php index 4ea7ada5d..3eaa7ee4c 100644 --- a/legacy/src/Service/ResourcesUtil.php +++ b/legacy/src/Service/ResourcesUtil.php @@ -309,20 +309,20 @@ public function containerProfiles(EnvironmentDeployment $deployment): array } /** - * Returns a project's build resources. + * Returns a project's build resources, with null for a value that is missing or of an unexpected type. * - * @return array{cpu: float, memory: int} + * @return array{cpu: float|null, memory: int|null} */ public function buildResources(Settings $settings): array { $current = $settings['build_resources'] ?? null; $cpu = is_array($current) ? $current['cpu'] ?? null : null; $memory = is_array($current) ? $current['memory'] ?? null : null; - if (!(is_int($cpu) || is_float($cpu)) || !is_int($memory)) { - throw new \RuntimeException('The project settings do not include build resources'); - } - return ['cpu' => (float) $cpu, 'memory' => $memory]; + return [ + 'cpu' => is_int($cpu) || is_float($cpu) ? (float) $cpu : null, + 'memory' => is_int($memory) ? $memory : null, + ]; } /**