diff --git a/README.md b/README.md index f95a596..f472a04 100644 --- a/README.md +++ b/README.md @@ -669,6 +669,45 @@ You can use it to set all projects of an organization to use a storage backend. php ./cli.php manage:set-organization-storage-backend [--force/-f] ``` +## List Storage Backends +Read-only inventory of a stack's storage backends with a cleanup verdict per backend. Nothing is modified. +Pairs with `manage:delete-backend` — the ID lists printed at the end are ready to paste into it. + +- Run the command + ``` + php ./cli.php manage:list-backends [--format=table|csv] [--verdict=VERDICT] [--unsupported] + ``` +Arguments: +- manage-token (required) Manage API token for the stack. +- stack-url (required) Full stack URL, e.g. https://connection.keboola.com. + +Options: +- --format Output format, `table` (default) or `csv`. In `csv` mode the summary goes to stderr so stdout stays parseable. +- --verdict Show only backends with the given verdict (validated against the list below). +- --unsupported Show only backends that are not snowflake/bigquery — i.e. both `DELETE_UNSUPPORTED_UNUSED` and `REVIEW_UNSUPPORTED_IN_USE` in one pass. Composable with `--verdict`. + +With filters active, the summary and the paste-ready delete ID lists cover the shown rows only +(the header says `Backends shown: X of Y`), so what you see is exactly what you'd delete. + +Verdicts (backends we keep are snowflake and bigquery; everything else is a decommissioned type +such as mysql/redshift/synapse/exasol/teradata, or a parked PoC such as supabase): +- `DELETE_UNSUPPORTED_UNUSED` — not snowflake/bigquery, 0 projects and 0 maintainers. Safe to delete. +- `DELETE_UNUSED` — snowflake/bigquery, 0 projects and 0 maintainers. +- `REVIEW_UNSUPPORTED_IN_USE` — not snowflake/bigquery, but still has projects or maintainers. +- `REVIEW_MAINTAINER_ONLY` — 0 projects, but a maintainer still points at it as its default backend. +- `KEEP` — has live projects. + +Counts come from `assignedProjectsCount` / `assignedMaintainersCount` and cover live projects only. +A backend showing 0 can still be blocked by soft-deleted, not-yet-purged projects — the delete guard refuses those. + +The command fetches the detail of every displayed backend (`Client::getStorageBackend()`, with the +client's built-in backoff) to add columns that are not in the list response: `loginType`, `keyRotated` +(= `keyPairLastRotatedAt`, date only), `dynBackends` (= `useDynamicBackends`), `useSso`, `ssoEnabled`, +`ssoConfigured`. Booleans render as 1/0, empty when the detail does not report the field (e.g. BigQuery +has no `loginType`). A failed detail call leaves the extra columns empty, keeps the row and reports the +affected backend IDs on stderr — verdicts and counts always come from the list response, so a failed +detail cannot change the delete lists. + ## Delete Storage Backend Delete one or more storage backends from a stack by their IDs. Dry-run by default. diff --git a/cli.php b/cli.php index 2908e23..8a26a0a 100644 --- a/cli.php +++ b/cli.php @@ -17,6 +17,7 @@ use Keboola\Console\Command\DeleteOwnerlessWorkspaces; use Keboola\Console\Command\DescribeOrganizationWorkspaces; use Keboola\Console\Command\LineageEventsExport; +use Keboola\Console\Command\ListStorageBackends; use Keboola\Console\Command\MassDeleteProjectWorkspaces; use Keboola\Console\Command\MassProjectEnableDynamicBackends; use Keboola\Console\Command\MassProjectExtendExpiration; @@ -71,4 +72,5 @@ $application->add(new OrganizationsAddFeature()); $application->add(new DeleteProjects()); $application->add(new DeleteStorageBackend()); +$application->add(new ListStorageBackends()); $application->run(); diff --git a/composer.json b/composer.json index 36cb303..2a7e41e 100644 --- a/composer.json +++ b/composer.json @@ -6,12 +6,13 @@ "keboola/sandboxes-service-api-client": "^2.1.1", "keboola/service-client": "^1.0", "keboola/storage-api-client": "^18.5.0", - "keboola/kbc-manage-api-php-client": "^7", + "keboola/kbc-manage-api-php-client": "^10.3", "symfony/event-dispatcher": "^7.4", "keboola/job-queue-api-php-client": "^5.2.0", "symfony/config": "^7.4", "keboola/php-datatypes": "^8.0", - "symfony/http-client": "^7.4" + "symfony/http-client": "^7.4", + "guzzlehttp/guzzle": "^7.0" }, "require-dev": { "squizlabs/php_codesniffer": "3.*", diff --git a/composer.lock b/composer.lock index 557b0b8..d3f5412 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "b460374ec27395d05b2d468a08b23055", + "content-hash": "a95ddafa49e9ddfe1542b567f39fe586", "packages": [ { "name": "aws/aws-crt-php", @@ -1201,30 +1201,21 @@ }, { "name": "keboola/kbc-manage-api-php-client", - "version": "v7.1.1", + "version": "v10.3.0", "source": { "type": "git", "url": "https://github.com/keboola/kbc-manage-api-php-client.git", - "reference": "c1549531015a70c479cd7398b9778028b6ee1e68" + "reference": "f4b9060f01f3e88b19df62ef3fa17aa6953a03a8" }, "dist": { "type": "zip", - "url": "https://api.github.com/repos/keboola/kbc-manage-api-php-client/zipball/c1549531015a70c479cd7398b9778028b6ee1e68", - "reference": "c1549531015a70c479cd7398b9778028b6ee1e68", + "url": "https://api.github.com/repos/keboola/kbc-manage-api-php-client/zipball/f4b9060f01f3e88b19df62ef3fa17aa6953a03a8", + "reference": "f4b9060f01f3e88b19df62ef3fa17aa6953a03a8", "shasum": "" }, "require": { - "guzzlehttp/guzzle": "^7.0|^6.1", - "php": "^7.4|^8.0" - }, - "require-dev": { - "keboola/coding-standard": "^15.0", - "keboola/phpunit-retry-annotations": "^0.3.0", - "keboola/storage-api-client": "^12.0", - "phpstan/phpdoc-parser": "^1.24.5", - "phpstan/phpstan": "^1.10", - "phpstan/phpstan-phpunit": "^1", - "phpunit/phpunit": "^7.0|^8.0" + "guzzlehttp/guzzle": "~7.0", + "php": ">=8.2" }, "type": "library", "autoload": { @@ -1245,9 +1236,9 @@ "description": "Keboola Management API Client", "support": { "issues": "https://github.com/keboola/kbc-manage-api-php-client/issues", - "source": "https://github.com/keboola/kbc-manage-api-php-client/tree/v7.1.1" + "source": "https://github.com/keboola/kbc-manage-api-php-client/tree/v10.3.0" }, - "time": "2024-04-05T12:49:35+00:00" + "time": "2026-06-16T05:40:34+00:00" }, { "name": "keboola/php-api-client-base", @@ -5943,5 +5934,5 @@ "php": "^8.3" }, "platform-dev": {}, - "plugin-api-version": "2.9.0" + "plugin-api-version": "2.6.0" } diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon new file mode 100644 index 0000000..95dfb66 --- /dev/null +++ b/phpstan-baseline.neon @@ -0,0 +1,883 @@ +parameters: + ignoreErrors: + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 5 + path: src/Keboola/Console/Command/AddFeature.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Keboola/Console/Command/AddFeature.php + + - + message: '#^Parameter \#1 \$templateStringId of method Keboola\\ManageApi\\Client\:\:addProjectTemplateFeature\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/AddFeature.php + + - + message: '#^Parameter \#1 \$templateStringId of method Keboola\\ManageApi\\Client\:\:getProjectTemplateFeatures\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/AddFeature.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 3 + path: src/Keboola/Console/Command/AddFeature.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 + path: src/Keboola/Console/Command/CleanupLeakedTestFeatures.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 4 + path: src/Keboola/Console/Command/CleanupLeakedTestFeatures.php + + - + message: '#^Cannot access offset ''type'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/CleanupLeakedTestFeatures.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 2 + path: src/Keboola/Console/Command/CleanupLeakedTestFeatures.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:removeFeature\(\) expects int\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/CleanupLeakedTestFeatures.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/CleanupLeakedTestFeatures.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/CleanupLeakedTestFeatures.php + + - + message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/CleanupLeakedTestFeatures.php + + - + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Parameter \#1 \$config of class Keboola\\StorageApi\\Client constructor expects array\{token\?\: string, url\?\: string, userAgent\?\: string, backoffMaxTries\?\: int, retryOnMaintenance\?\: bool, awsRetries\?\: int, logger\?\: Psr\\Log\\LoggerInterface, jobPollRetryDelay\?\: callable\(\)\: mixed, \.\.\.\}, array\{token\: mixed, url\: non\-empty\-string, logger\: Symfony\\Component\\Console\\Logger\\ConsoleLogger\} given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\StorageApi\\Tokens\:\:dropToken\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:createProjectStorageToken\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Parameter \#1 \$value of function count expects array\|Countable, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Parameter \#2 \$config of class Keboola\\StorageApi\\BranchAwareClient constructor expects array\{token\?\: string, url\?\: string, userAgent\?\: string, backoffMaxTries\?\: int, retryOnMaintenance\?\: bool, awsRetries\?\: int, logger\?\: Psr\\Log\\LoggerInterface, jobPollRetryDelay\?\: callable\(\)\: mixed, \.\.\.\}, array\{token\: mixed, url\: non\-empty\-string, backoffMaxTries\: 1\} given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOrphanedWorkspaces.php + + - + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 5 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#1 \$config of class Keboola\\StorageApi\\Client constructor expects array\{token\?\: string, url\?\: string, userAgent\?\: string, backoffMaxTries\?\: int, retryOnMaintenance\?\: bool, awsRetries\?\: int, logger\?\: Psr\\Log\\LoggerInterface, jobPollRetryDelay\?\: callable\(\)\: mixed, \.\.\.\}, array\{token\: mixed, url\: non\-empty\-string, backoffMaxTries\: 1, logger\: Symfony\\Component\\Console\\Logger\\ConsoleLogger\} given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\StorageApi\\Tokens\:\:dropToken\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:createProjectStorageToken\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#1 \$value of function count expects array\|Countable, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#2 \$config of class Keboola\\StorageApi\\BranchAwareClient constructor expects array\{token\?\: string, url\?\: string, userAgent\?\: string, backoffMaxTries\?\: int, retryOnMaintenance\?\: bool, awsRetries\?\: int, logger\?\: Psr\\Log\\LoggerInterface, jobPollRetryDelay\?\: callable\(\)\: mixed, \.\.\.\}, array\{token\: mixed, url\: non\-empty\-string\} given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#2 \$storageToken of class Keboola\\JobQueueClient\\Client constructor expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#2 \$token of class Keboola\\Console\\Command\\EditorServiceClient constructor expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 6 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \$storageToken of class Keboola\\SandboxesServiceApiClient\\Apps\\AppsApiClient constructor expects non\-empty\-string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrganizationOwnerlessWorkspaces.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteOrphanedWorkspaces.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:deleteProject\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteProjects.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:getDeletedProject\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteProjects.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/DeleteProjects.php + + - + message: '#^Cannot access offset ''host'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/DeleteStorageBackend.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/DeleteStorageBackend.php + + - + message: '#^Cannot access offset ''owner'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/DeleteStorageBackend.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteStorageBackend.php + + - + message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeleteStorageBackend.php + + - + message: '#^Possibly invalid array key type mixed\.$#' + identifier: offsetAccess.invalidOffset + count: 1 + path: src/Keboola/Console/Command/DeleteStorageBackend.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 3 + path: src/Keboola/Console/Command/DeletedProjectsPurge.php + + - + message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DeletedProjectsPurge.php + + - + message: '#^Part \$response\[''commandExecutionId''\] \(mixed\) of encapsed string cannot be cast to string\.$#' + identifier: encapsedStringPart.nonString + count: 1 + path: src/Keboola/Console/Command/DeletedProjectsPurge.php + + - + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Cannot access offset ''email'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 6 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#1 \$config of class Keboola\\StorageApi\\Client constructor expects array\{token\?\: string, url\?\: string, userAgent\?\: string, backoffMaxTries\?\: int, retryOnMaintenance\?\: bool, awsRetries\?\: int, logger\?\: Psr\\Log\\LoggerInterface, jobPollRetryDelay\?\: callable\(\)\: mixed, \.\.\.\}, array\{token\: mixed, url\: non\-empty\-string, logger\: Symfony\\Component\\Console\\Logger\\ConsoleLogger\} given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:listProjectUsers\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\StorageApi\\Tokens\:\:dropToken\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:createProjectStorageToken\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#1 \$value of function count expects array\|Countable, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#2 \$config of class Keboola\\StorageApi\\BranchAwareClient constructor expects array\{token\?\: string, url\?\: string, userAgent\?\: string, backoffMaxTries\?\: int, retryOnMaintenance\?\: bool, awsRetries\?\: int, logger\?\: Psr\\Log\\LoggerInterface, jobPollRetryDelay\?\: callable\(\)\: mixed, \.\.\.\}, array\{token\: mixed, url\: non\-empty\-string\} given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 3 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/DescribeOrganizationWorkspaces.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:getProject\(\) expects int, string given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/MassProjectEnableDynamicBackends.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:addProjectFeature\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MassProjectEnableDynamicBackends.php + + - + message: '#^Parameter \#2 \$haystack of function in_array expects array, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/MassProjectEnableDynamicBackends.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/MassProjectEnableDynamicBackends.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:getProject\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:updateProject\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Parameter \#5 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Parameter \#6 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Parameter \#7 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Parameter \#8 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MassProjectExtendExpiration.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 4 + path: src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 1 + path: src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php + + - + message: '#^Parameter \#1 \$config of class Keboola\\StorageApi\\Client constructor expects array\{token\?\: string, url\?\: string, userAgent\?\: string, backoffMaxTries\?\: int, retryOnMaintenance\?\: bool, awsRetries\?\: int, logger\?\: Psr\\Log\\LoggerInterface, jobPollRetryDelay\?\: callable\(\)\: mixed, \.\.\.\}, array\{url\: string, token\: mixed\} given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:getProject\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php + + - + message: '#^Parameter \#1 \$maintainerId of method Keboola\\ManageApi\\Client\:\:listMaintainerOrganizations\(\) expects int\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php + + - + message: '#^Parameter \#1 \$organizationId of method Keboola\\ManageApi\\Client\:\:listOrganizationProjects\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:createProjectStorageToken\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/MigrateDataAppsOrchestratorTasks.php + + - + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 5 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#1 \$config of class Keboola\\StorageApi\\Client constructor expects array\{token\?\: string, url\?\: string, userAgent\?\: string, backoffMaxTries\?\: int, retryOnMaintenance\?\: bool, awsRetries\?\: int, logger\?\: Psr\\Log\\LoggerInterface, jobPollRetryDelay\?\: callable\(\)\: mixed, \.\.\.\}, array\{token\: mixed, url\: non\-empty\-string\} given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\StorageApi\\Tokens\:\:dropToken\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:createProjectStorageToken\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:disableProject\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:enableProject\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#1 \$value of function count expects array\|Countable, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#2 \$projectId of method Keboola\\Console\\Command\\OrganizationIntoMaintenanceMode\:\:areThereRunningJobs\(\) expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#2 \$storageToken of class Keboola\\JobQueueClient\\Client constructor expects string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/OrganizationIntoMaintenanceMode.php + + - + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: src/Keboola/Console/Command/OrganizationResetWorkspacePasswords.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 + path: src/Keboola/Console/Command/OrganizationResetWorkspacePasswords.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 1 + path: src/Keboola/Console/Command/OrganizationResetWorkspacePasswords.php + + - + message: '#^Parameter \#1 \$value of function count expects array\|Countable, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationResetWorkspacePasswords.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/OrganizationResetWorkspacePasswords.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationResetWorkspacePasswords.php + + - + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 7 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Cannot access offset ''snowflake'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Cannot cast mixed to int\.$#' + identifier: cast.int + count: 1 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 1 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Parameter \#1 \$value of function count expects array\|Countable, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Parameter \#2 \$projectId of method Keboola\\Console\\Command\\OrganizationStorageBackend\:\:waitForProjectToMigrate\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 4 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/OrganizationStorageBackend.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/OrganizationsAddFeature.php + + - + message: '#^Parameter \#1 \$organizationId of method Keboola\\ManageApi\\Client\:\:getOrganization\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationsAddFeature.php + + - + message: '#^Parameter \#2 \$array of function array_map expects array, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationsAddFeature.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationsAddFeature.php + + - + message: '#^Parameter \#4 \$projectIds of method Keboola\\Console\\Command\\ProjectsAddFeature\:\:addFeatureToSelectedProjects\(\) expects array\, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/OrganizationsAddFeature.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Keboola/Console/Command/ProjectsAddFeature.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/ProjectsAddFeature.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:getProject\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsAddFeature.php + + - + message: '#^Parameter \#1 \$maintainerId of method Keboola\\ManageApi\\Client\:\:listMaintainerOrganizations\(\) expects int\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsAddFeature.php + + - + message: '#^Parameter \#1 \$organizationId of method Keboola\\ManageApi\\Client\:\:listOrganizationProjects\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsAddFeature.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php + + - + message: '#^Cannot cast mixed to string\.$#' + identifier: cast.string + count: 2 + path: src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:getProject\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php + + - + message: '#^Parameter \#1 \$organizationId of method Keboola\\ManageApi\\Client\:\:listOrganizationProjects\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsAddFeatureConditionally.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 + path: src/Keboola/Console/Command/ProjectsRemoveFeature.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/ProjectsRemoveFeature.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:getProject\(\) expects int, string given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsRemoveFeature.php + + - + message: '#^Parameter \#1 \$maintainerId of method Keboola\\ManageApi\\Client\:\:listMaintainerOrganizations\(\) expects int\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsRemoveFeature.php + + - + message: '#^Parameter \#1 \$organizationId of method Keboola\\ManageApi\\Client\:\:listOrganizationProjects\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsRemoveFeature.php + + - + message: '#^Parameter \#1 \$projectId of method Keboola\\ManageApi\\Client\:\:removeProjectFeature\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsRemoveFeature.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsRemoveFeature.php + + - + message: '#^Parameter \#3 \$projectInfo of method Keboola\\Console\\Command\\ProjectsRemoveFeature\:\:removeFeatureFromProject\(\) expects array\, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/ProjectsRemoveFeature.php + + - + message: '#^Argument of an invalid type mixed supplied for foreach, only iterables are supported\.$#' + identifier: foreach.nonIterable + count: 1 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 3 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 1 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Cannot cast mixed to int\.$#' + identifier: cast.int + count: 3 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Parameter \#1 \$value of function count expects array\|Countable, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Parameter \#2 \$projectUsers of method Keboola\\Console\\Command\\RemoveUserFromOrganizationProjects\:\:isUserInProject\(\) expects array\\>, array\ given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Parameter \#2 \$userEmailOrId of method Keboola\\ManageApi\\Client\:\:removeUserFromProject\(\) expects int\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Parameter \#4 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/RemoveUserFromOrganizationProjects.php + + - + message: '#^Cannot access offset ''id'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 6 + path: src/Keboola/Console/Command/UpdateDataRetention.php + + - + message: '#^Cannot access offset ''name'' on mixed\.$#' + identifier: offsetAccess.nonOffsetAccessible + count: 2 + path: src/Keboola/Console/Command/UpdateDataRetention.php + + - + message: '#^Parameter \#1 \$id of method Keboola\\ManageApi\\Client\:\:updateProject\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/UpdateDataRetention.php + + - + message: '#^Parameter \#1 \$maintainerId of method Keboola\\ManageApi\\Client\:\:listMaintainerOrganizations\(\) expects int\|string, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/UpdateDataRetention.php + + - + message: '#^Parameter \#1 \$organizationId of method Keboola\\ManageApi\\Client\:\:listOrganizationProjects\(\) expects int, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/UpdateDataRetention.php + + - + message: '#^Parameter \#2 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 2 + path: src/Keboola/Console/Command/UpdateDataRetention.php + + - + message: '#^Parameter \#3 \$projectInfo of method Keboola\\Console\\Command\\UpdateDataRetention\:\:updateProjectDataRetention\(\) expects array\, mixed given\.$#' + identifier: argument.type + count: 1 + path: src/Keboola/Console/Command/UpdateDataRetention.php + + - + message: '#^Parameter \#3 \.\.\.\$values of function sprintf expects bool\|float\|int\|string\|null, mixed given\.$#' + identifier: argument.type + count: 4 + path: src/Keboola/Console/Command/UpdateDataRetention.php diff --git a/phpstan.neon b/phpstan.neon index 0bb5bf0..f599ed9 100644 --- a/phpstan.neon +++ b/phpstan.neon @@ -1,3 +1,6 @@ +includes: + - phpstan-baseline.neon + parameters: level: 9 paths: diff --git a/src/Keboola/Console/Command/ListStorageBackends.php b/src/Keboola/Console/Command/ListStorageBackends.php new file mode 100644 index 0000000..62c996c --- /dev/null +++ b/src/Keboola/Console/Command/ListStorageBackends.php @@ -0,0 +1,298 @@ +setName('manage:list-backends') + ->setDescription( + 'Read-only: list all storage backends of a stack with project/maintainer counts ' + . 'and a cleanup verdict. Prints ID lists ready for manage:delete-backend.' + ) + ->addArgument(self::ARGUMENT_TOKEN, InputArgument::REQUIRED, 'manage api token') + ->addArgument(self::ARGUMENT_URL, InputArgument::REQUIRED, 'Stack URL') + ->addOption( + self::OPTION_FORMAT, + null, + InputOption::VALUE_REQUIRED, + 'Output format: table or csv', + self::FORMAT_TABLE + ) + ->addOption( + self::OPTION_VERDICT, + null, + InputOption::VALUE_REQUIRED, + 'Show only backends with this verdict (e.g. DELETE_UNSUPPORTED_UNUSED)' + ) + ->addOption( + self::OPTION_UNSUPPORTED, + null, + InputOption::VALUE_NONE, + 'Show only backends that are not snowflake/bigquery' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $token = $input->getArgument(self::ARGUMENT_TOKEN); + assert(is_string($token)); + $url = $input->getArgument(self::ARGUMENT_URL); + assert(is_string($url)); + + $format = $input->getOption(self::OPTION_FORMAT); + if (!in_array($format, [self::FORMAT_TABLE, self::FORMAT_CSV], true)) { + $output->writeln('Unknown format, use table or csv'); + return 1; + } + $verdictFilter = $input->getOption(self::OPTION_VERDICT); + assert($verdictFilter === null || is_string($verdictFilter)); + if ($verdictFilter !== null && !in_array($verdictFilter, StorageBackendInventory::VERDICTS, true)) { + $output->writeln(sprintf( + 'Unknown verdict "%s", use one of: %s', + $verdictFilter, + implode(', ', StorageBackendInventory::VERDICTS) + )); + return 1; + } + $unsupportedOnly = (bool) $input->getOption(self::OPTION_UNSUPPORTED); + + $client = new Client(['url' => $url, 'token' => $token]); + $inventory = new StorageBackendInventory(); + + $stderr = $output instanceof ConsoleOutputInterface ? $output->getErrorOutput() : $output; + + $backends = $client->listStorageBackend(); + + $rows = []; + $skipped = 0; + foreach ($backends as $backend) { + assert(is_array($backend)); + $row = $inventory->buildRow($backend); + if ($row === null) { + $skipped++; + continue; + } + $rows[] = $row; + } + usort($rows, fn (array $a, array $b) => $a['id'] <=> $b['id']); + if ($skipped > 0) { + $stderr->writeln(sprintf( + 'Warning: skipped %d list entr%s without a usable numeric id.', + $skipped, + $skipped === 1 ? 'y' : 'ies' + )); + } + + $visibleRows = $inventory->filterRows($rows, $unsupportedOnly, $verdictFilter); + + // Detail (loginType, keyRotated, SSO flags) is fetched only for the rows that will + // be displayed — verdicts and counts come from the list response alone. + $visibleRows = $this->fetchDetails($client, $inventory, $visibleRows, $stderr); + + if ($format === self::FORMAT_CSV) { + $this->renderCsv($output, $visibleRows); + } else { + $this->renderTable($output, $visibleRows); + } + + $this->renderSummary( + $output, + $inventory->summarize($visibleRows), + count($visibleRows), + count($rows), + $format === self::FORMAT_CSV + ); + + return 0; + } + + /** + * @param array> $rows + * @return array> + */ + private function fetchDetails( + Client $client, + StorageBackendInventory $inventory, + array $rows, + OutputInterface $stderr + ): array { + if ($rows === []) { + return []; + } + + $stderr->writeln(sprintf('Fetching details for %d backends...', count($rows))); + + $failed = []; + foreach ($rows as $i => $row) { + $detail = []; + try { + $detail = $client->getStorageBackend((int) $row['id']); + } catch (Throwable) { + // Keep the row usable even when a single detail call fails. + } + if ($detail === []) { + $failed[] = (string) $row['id']; + } + $rows[$i] = $inventory->applyDetail($row, $detail); + } + + if ($failed !== []) { + // Counts and verdicts come from the list response; only the detail-added columns are affected. + $stderr->writeln(sprintf( + 'Warning: detail fetch failed for backend(s) %s — ' + . 'loginType/keyRotated/SSO/dynBackends columns are empty there, not authoritative.', + implode(',', $failed) + )); + } + + return $rows; + } + + /** + * @param array> $rows + */ + private function renderCsv(OutputInterface $output, array $rows): void + { + $stream = fopen('php://temp', 'r+'); + assert($stream !== false); + fputcsv($stream, self::COLUMNS, ',', '"', ''); + foreach ($rows as $row) { + $cells = []; + foreach (self::COLUMNS as $column) { + $cells[] = (string) $row[$column]; + } + fputcsv($stream, $cells, ',', '"', ''); + } + rewind($stream); + $csv = stream_get_contents($stream); + fclose($stream); + assert(is_string($csv)); + $output->write($csv, false, OutputInterface::OUTPUT_RAW); + } + + /** + * @param array> $rows + */ + private function renderTable(OutputInterface $output, array $rows): void + { + $table = new Table($output); + $table->setHeaders(self::COLUMNS); + foreach ($rows as $row) { + $cells = []; + foreach (self::COLUMNS as $column) { + $cells[] = (string) $row[$column]; + } + $table->addRow($cells); + } + $table->render(); + } + + /** + * @param array{byType: array, byVerdict: array, + * deleteIds: array>, needsReview: int} $summary + */ + private function renderSummary( + OutputInterface $output, + array $summary, + int $visibleCount, + int $totalCount, + bool $toStdErr + ): void { + // In CSV mode keep stdout parseable — the summary goes to stderr. + $out = $toStdErr && $output instanceof ConsoleOutputInterface + ? $output->getErrorOutput() + : $output; + + $out->writeln(''); + if ($visibleCount === $totalCount) { + $out->writeln(sprintf('Total backends: %d', $totalCount)); + } else { + $out->writeln(sprintf( + 'Backends shown: %d of %d (filters active — summary and ID lists cover the shown rows only)', + $visibleCount, + $totalCount + )); + } + $out->writeln(''); + $out->writeln('By backend type:'); + foreach ($summary['byType'] as $type => $count) { + $out->writeln(sprintf(' %-12s %d', $type, $count)); + } + $out->writeln(''); + $out->writeln('By verdict:'); + foreach ($summary['byVerdict'] as $verdict => $count) { + $out->writeln(sprintf(' %-24s %d', $verdict, $count)); + } + + $out->writeln(''); + $out->writeln('Delete candidates (paste into manage:delete-backend):'); + foreach ($summary['deleteIds'] as $verdict => $ids) { + $out->writeln(''); + $out->writeln(sprintf(' %s (%d):', $verdict, count($ids))); + $out->writeln($ids === [] ? ' -' : ' ' . implode(',', $ids)); + } + + if ($summary['needsReview'] > 0) { + $out->writeln(''); + $out->writeln(sprintf( + '%d backend(s) need a manual look before deletion (REVIEW_* verdicts above).', + $summary['needsReview'] + )); + } + $out->writeln(''); + $out->writeln( + 'Note: counts cover live projects only. A backend showing 0 can still be blocked by ' + . 'soft-deleted, not-yet-purged projects — the delete guard will refuse those.' + ); + } +} diff --git a/src/Keboola/Console/Command/StorageBackendInventory.php b/src/Keboola/Console/Command/StorageBackendInventory.php new file mode 100644 index 0000000..08573fb --- /dev/null +++ b/src/Keboola/Console/Command/StorageBackendInventory.php @@ -0,0 +1,200 @@ + $backend + * @return array{id: int, backend: string, host: string, region: string, owner: string, + * technicalOwner: string, projects: int, maintainers: int, buckets: int, loginType: string, + * keyRotated: string, dynBackends: string, useSso: string, ssoEnabled: string, + * ssoConfigured: string, created: string, verdict: string}|null + */ + public function buildRow(array $backend): ?array + { + if (!isset($backend['id']) || !is_numeric($backend['id']) || (int) $backend['id'] <= 0) { + return null; + } + + $stats = $backend['stats'] ?? []; + if (!is_array($stats)) { + $stats = []; + } + + $type = $this->asString($backend['backend'] ?? ''); + $projects = $this->asInt($backend['assignedProjectsCount'] ?? 0); + $maintainers = $this->asInt($backend['assignedMaintainersCount'] ?? 0); + + return [ + 'id' => (int) $backend['id'], + 'backend' => $type, + // BigQuery backends have no host, they report a folderId instead. + 'host' => $this->asString($backend['host'] ?? $backend['folderId'] ?? ''), + 'region' => $this->asString($backend['region'] ?? ''), + 'owner' => $this->asString($backend['owner'] ?? ''), + 'technicalOwner' => $this->asString($backend['technicalOwner'] ?? ''), + 'projects' => $projects, + 'maintainers' => $maintainers, + 'buckets' => $this->asInt($stats['bucketsCount'] ?? 0), + 'loginType' => '', + 'keyRotated' => '', + 'dynBackends' => '', + 'useSso' => '', + 'ssoEnabled' => '', + 'ssoConfigured' => '', + 'created' => substr($this->asString($backend['created'] ?? ''), 0, 10), + 'verdict' => $this->resolveVerdict($type, $projects, $maintainers), + ]; + } + + /** + * Fills the detail-only columns from a detail response. Counts and verdict always come + * from the list response, so a missing/failed detail cannot change what gets deleted. + * + * @param array $row + * @param array $detail + * @return array + */ + public function applyDetail(array $row, array $detail): array + { + $row['loginType'] = $this->asString($detail['loginType'] ?? ''); + $row['keyRotated'] = substr($this->asString($detail['keyPairLastRotatedAt'] ?? ''), 0, 10); + $row['dynBackends'] = $this->asBool($detail['useDynamicBackends'] ?? null); + $row['useSso'] = $this->asBool($detail['useSso'] ?? null); + $row['ssoEnabled'] = $this->asBool($detail['isSsoEnabled'] ?? null); + $row['ssoConfigured'] = $this->asBool($detail['isSsoConfigured'] ?? null); + return $row; + } + + /** + * @param array> $rows + * @return array> + */ + public function filterRows(array $rows, bool $unsupportedOnly, ?string $verdict): array + { + if ($unsupportedOnly) { + $rows = array_filter($rows, fn (array $r) => !in_array($r['backend'], self::KEPT_BACKENDS, true)); + } + if ($verdict !== null) { + $rows = array_filter($rows, fn (array $r) => $r['verdict'] === $verdict); + } + return array_values($rows); + } + + public function resolveVerdict(string $type, int $projects, int $maintainers): string + { + $isUnsupported = !in_array($type, self::KEPT_BACKENDS, true); + $isUnused = $projects === 0 && $maintainers === 0; + + if ($isUnsupported) { + return $isUnused ? self::VERDICT_DELETE_UNSUPPORTED_UNUSED : self::VERDICT_REVIEW_UNSUPPORTED_IN_USE; + } + if ($isUnused) { + return self::VERDICT_DELETE_UNUSED; + } + if ($projects === 0) { + // No projects, but a maintainer still points at it as its default backend. + return self::VERDICT_REVIEW_MAINTAINER_ONLY; + } + return self::VERDICT_KEEP; + } + + /** + * @param array> $rows + * @return array{byType: array, byVerdict: array, + * deleteIds: array>, needsReview: int} + */ + public function summarize(array $rows): array + { + $byType = []; + $byVerdict = []; + foreach ($rows as $row) { + $byType[(string) $row['backend']] = ($byType[(string) $row['backend']] ?? 0) + 1; + $byVerdict[(string) $row['verdict']] = ($byVerdict[(string) $row['verdict']] ?? 0) + 1; + } + arsort($byType); + arsort($byVerdict); + + $deleteIds = []; + foreach (self::DELETE_VERDICTS as $verdict) { + $deleteIds[$verdict] = []; + foreach ($rows as $row) { + if ($row['verdict'] === $verdict) { + $deleteIds[$verdict][] = (string) $row['id']; + } + } + } + + $needsReview = 0; + foreach ($rows as $row) { + if (in_array($row['verdict'], self::REVIEW_VERDICTS, true)) { + $needsReview++; + } + } + + return [ + 'byType' => $byType, + 'byVerdict' => $byVerdict, + 'deleteIds' => $deleteIds, + 'needsReview' => $needsReview, + ]; + } + + private function asString(mixed $value): string + { + return is_scalar($value) ? (string) $value : ''; + } + + private function asInt(mixed $value): int + { + return is_numeric($value) ? (int) $value : 0; + } + + private function asBool(mixed $value): string + { + if ($value === null) { + return ''; + } + return $value ? '1' : '0'; + } +} diff --git a/tests/StorageBackendInventoryTest.php b/tests/StorageBackendInventoryTest.php new file mode 100644 index 0000000..cea0170 --- /dev/null +++ b/tests/StorageBackendInventoryTest.php @@ -0,0 +1,255 @@ +inventory = new StorageBackendInventory(); + } + + /** + * @return array + */ + public static function verdictProvider(): array + { + return [ + 'unsupported unused' => + ['mysql', 0, 0, StorageBackendInventory::VERDICT_DELETE_UNSUPPORTED_UNUSED], + 'unsupported with projects' => + ['redshift', 3, 0, StorageBackendInventory::VERDICT_REVIEW_UNSUPPORTED_IN_USE], + 'unsupported with maintainer only' => + ['supabase', 0, 1, StorageBackendInventory::VERDICT_REVIEW_UNSUPPORTED_IN_USE], + 'snowflake unused' => ['snowflake', 0, 0, StorageBackendInventory::VERDICT_DELETE_UNUSED], + 'bigquery unused' => ['bigquery', 0, 0, StorageBackendInventory::VERDICT_DELETE_UNUSED], + 'snowflake maintainer only' => ['snowflake', 0, 1, StorageBackendInventory::VERDICT_REVIEW_MAINTAINER_ONLY], + 'snowflake live' => ['snowflake', 5, 0, StorageBackendInventory::VERDICT_KEEP], + 'bigquery live with maintainer' => ['bigquery', 1, 1, StorageBackendInventory::VERDICT_KEEP], + ]; + } + + #[DataProvider('verdictProvider')] + public function testResolveVerdict(string $type, int $projects, int $maintainers, string $expected): void + { + $this->assertSame($expected, $this->inventory->resolveVerdict($type, $projects, $maintainers)); + } + + public function testBuildRowMapsListFields(): void + { + $row = $this->inventory->buildRow([ + 'id' => 42, + 'backend' => 'snowflake', + 'host' => 'acme.snowflakecomputing.com', + 'region' => 'us-east-1', + 'owner' => 'client-Acme', + 'technicalOwner' => 'byodb', + 'assignedProjectsCount' => 7, + 'assignedMaintainersCount' => 1, + 'stats' => ['bucketsCount' => 3], + 'created' => '2024-05-04T12:00:00+0200', + ]); + + $this->assertNotNull($row); + $this->assertSame(42, $row['id']); + $this->assertSame('acme.snowflakecomputing.com', $row['host']); + $this->assertSame(7, $row['projects']); + $this->assertSame(1, $row['maintainers']); + $this->assertSame(3, $row['buckets']); + $this->assertSame('2024-05-04', $row['created']); + $this->assertSame(StorageBackendInventory::VERDICT_KEEP, $row['verdict']); + // Detail columns start empty; they are filled by applyDetail(). + $this->assertSame('', $row['loginType']); + $this->assertSame('', $row['keyRotated']); + } + + public function testBuildRowFallsBackToFolderIdForBigquery(): void + { + $row = $this->inventory->buildRow(['id' => 9, 'backend' => 'bigquery', 'folderId' => 'folders/123']); + + $this->assertNotNull($row); + $this->assertSame('folders/123', $row['host']); + } + + public function testBuildRowDefaultsMissingFields(): void + { + $row = $this->inventory->buildRow(['id' => 5]); + + $this->assertNotNull($row); + $this->assertSame('', $row['backend']); + $this->assertSame(0, $row['projects']); + $this->assertSame(0, $row['maintainers']); + $this->assertSame(0, $row['buckets']); + } + + /** + * @return array}> + */ + public static function invalidIdProvider(): array + { + return [ + 'missing id' => [['backend' => 'snowflake']], + 'non-numeric id' => [['id' => 'abc', 'backend' => 'snowflake']], + 'zero id' => [['id' => 0, 'backend' => 'snowflake']], + 'negative id' => [['id' => -3, 'backend' => 'snowflake']], + ]; + } + + /** + * @param array $backend + */ + #[DataProvider('invalidIdProvider')] + public function testBuildRowRejectsEntriesWithoutUsableId(array $backend): void + { + $this->assertNull($this->inventory->buildRow($backend)); + } + + public function testApplyDetailFillsDetailColumns(): void + { + $row = $this->inventory->buildRow(['id' => 1, 'backend' => 'snowflake']); + $this->assertNotNull($row); + + $row = $this->inventory->applyDetail($row, [ + 'loginType' => 'snowflake-service-keypair', + 'keyPairLastRotatedAt' => '2026-08-04T12:00:06+0200', + 'useDynamicBackends' => true, + 'useSso' => false, + 'isSsoEnabled' => true, + 'isSsoConfigured' => false, + ]); + + $this->assertSame('snowflake-service-keypair', $row['loginType']); + $this->assertSame('2026-08-04', $row['keyRotated']); + $this->assertSame('1', $row['dynBackends']); + $this->assertSame('0', $row['useSso']); + $this->assertSame('1', $row['ssoEnabled']); + $this->assertSame('0', $row['ssoConfigured']); + } + + public function testApplyDetailWithEmptyDetailLeavesColumnsEmpty(): void + { + $row = $this->inventory->buildRow(['id' => 1, 'backend' => 'bigquery']); + $this->assertNotNull($row); + + $row = $this->inventory->applyDetail($row, []); + + $this->assertSame('', $row['loginType']); + $this->assertSame('', $row['keyRotated']); + $this->assertSame('', $row['dynBackends']); + $this->assertSame('', $row['useSso']); + } + + public function testApplyDetailDoesNotTouchCountsOrVerdict(): void + { + $row = $this->inventory->buildRow([ + 'id' => 17, + 'backend' => 'snowflake', + 'assignedProjectsCount' => 300, + 'assignedMaintainersCount' => 2, + ]); + $this->assertNotNull($row); + + $row = $this->inventory->applyDetail($row, ['assignedProjectsCount' => 0, 'loginType' => 'default']); + + $this->assertSame(300, $row['projects']); + $this->assertSame(2, $row['maintainers']); + $this->assertSame(StorageBackendInventory::VERDICT_KEEP, $row['verdict']); + } + + /** + * @return array> + */ + private function sampleRows(): array + { + $build = function (int $id, string $type, int $projects, int $maintainers): array { + $row = $this->inventory->buildRow([ + 'id' => $id, + 'backend' => $type, + 'assignedProjectsCount' => $projects, + 'assignedMaintainersCount' => $maintainers, + ]); + $this->assertNotNull($row); + return $row; + }; + + return [ + $build(1, 'snowflake', 100, 2), // KEEP + $build(2, 'snowflake', 0, 0), // DELETE_UNUSED + $build(3, 'mysql', 0, 0), // DELETE_UNSUPPORTED_UNUSED + $build(4, 'redshift', 2, 0), // REVIEW_UNSUPPORTED_IN_USE + $build(5, 'bigquery', 0, 1), // REVIEW_MAINTAINER_ONLY + ]; + } + + public function testFilterRowsUnsupportedOnly(): void + { + $filtered = $this->inventory->filterRows($this->sampleRows(), true, null); + + $this->assertSame([3, 4], array_column($filtered, 'id')); + } + + public function testFilterRowsByVerdict(): void + { + $filtered = $this->inventory->filterRows( + $this->sampleRows(), + false, + StorageBackendInventory::VERDICT_DELETE_UNUSED + ); + + $this->assertSame([2], array_column($filtered, 'id')); + } + + public function testFilterRowsComposesBothFilters(): void + { + $filtered = $this->inventory->filterRows( + $this->sampleRows(), + true, + StorageBackendInventory::VERDICT_DELETE_UNSUPPORTED_UNUSED + ); + + $this->assertSame([3], array_column($filtered, 'id')); + } + + public function testFilterRowsWithoutFiltersReturnsAll(): void + { + $this->assertCount(5, $this->inventory->filterRows($this->sampleRows(), false, null)); + } + + public function testSummarize(): void + { + $summary = $this->inventory->summarize($this->sampleRows()); + + $this->assertSame(['snowflake' => 2, 'mysql' => 1, 'redshift' => 1, 'bigquery' => 1], $summary['byType']); + $this->assertSame(1, $summary['byVerdict'][StorageBackendInventory::VERDICT_KEEP]); + $this->assertSame( + ['2'], + $summary['deleteIds'][StorageBackendInventory::VERDICT_DELETE_UNUSED] + ); + $this->assertSame( + ['3'], + $summary['deleteIds'][StorageBackendInventory::VERDICT_DELETE_UNSUPPORTED_UNUSED] + ); + $this->assertSame(2, $summary['needsReview']); + } + + public function testSummarizeOnFilteredRowsCoversOnlyThoseRows(): void + { + $filtered = $this->inventory->filterRows($this->sampleRows(), true, null); + $summary = $this->inventory->summarize($filtered); + + // The paste-ready delete list must not leak IDs hidden by the filter (e.g. id 2). + $this->assertSame( + ['3'], + $summary['deleteIds'][StorageBackendInventory::VERDICT_DELETE_UNSUPPORTED_UNUSED] + ); + $this->assertSame([], $summary['deleteIds'][StorageBackendInventory::VERDICT_DELETE_UNUSED]); + } +}