From 04bf7c37a0d2785558f15d880b189ac3649f7078 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Thu, 11 Sep 2025 15:28:14 +0200 Subject: [PATCH 01/10] Init new command to mass project deletion --- cli.php | 2 +- .../Console/Command/DeleteProjects.php | 129 ++++++++++++++++++ 2 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 src/Keboola/Console/Command/DeleteProjects.php diff --git a/cli.php b/cli.php index 6382762..2a1ce88 100644 --- a/cli.php +++ b/cli.php @@ -6,6 +6,7 @@ use Keboola\Console\Command\AddFeature; use Keboola\Console\Command\AllStacksIterator; +use Keboola\Console\Command\DeleteProjects; use Keboola\Console\Command\DeleteStorageBackend; use Keboola\Console\Command\DeleteOrganizationOrphanedWorkspaces; use Keboola\Console\Command\DeleteOrganizationOwnerlessWorkspaces; @@ -58,5 +59,4 @@ $application->add(new UpdateDataRetention()); $application->add(new OrganizationResetWorkspacePasswords()); $application->add(new OrganizationsAddFeature()); -$application->add(new DeleteStorageBackend()); $application->run(); diff --git a/src/Keboola/Console/Command/DeleteProjects.php b/src/Keboola/Console/Command/DeleteProjects.php new file mode 100644 index 0000000..d39d312 --- /dev/null +++ b/src/Keboola/Console/Command/DeleteProjects.php @@ -0,0 +1,129 @@ +setName('manage:delete-projects') + ->setDescription('Delete all projects specified by project IDs') + ->addArgument('token', InputArgument::REQUIRED, 'manage token') + ->addArgument('url', InputArgument::REQUIRED, 'Stack URL. Including https://') + ->addArgument('projects', InputArgument::REQUIRED, 'list of IDs separated by comma ("1,7,146")') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Will actually do the work, otherwise it\'s dry run'); + } + + public function execute(InputInterface $input, OutputInterface $output): ?int + { + $apiToken = $input->getArgument('token'); + $apiUrl = $input->getArgument('url'); + $projects = $input->getArgument('projects'); + + $force = (bool) $input->getOption('force'); + + $client = $this->createClient($apiUrl, $apiToken); + + $projectIds = array_filter(explode(',', $projects), 'is_numeric'); + $this->deleteProjects($client, $output, $projectIds, $force); + $output->writeln(''); + + $output->writeln('DONE with following results:'); + $this->printResult($output); + + if (!$force) { + $output->writeln(''); + $output->writeln('Command was run in dry-run mode. To actually apply changes run it with --force flag.'); + } + + return 0; + } + + private function createClient(string $host, string $token): Client + { + return new Client([ + 'url' => $host, + 'token' => $token, + ]); + } + + private function deleteProjects( + Client $client, + OutputInterface $output, + array $projectIds, + bool $force + ): void { + foreach ($projectIds as $projectId) { + $output->write(sprintf('Project %s: ', $projectId)); + + try { + $project = $client->getProject($projectId); + $this->deleteSingleProject($client, $output, $project, $force); + } catch (ClientException $e) { + if ($e->getCode() === 404) { + $output->writeln('not found'); + } else { + $output->writeln(sprintf('error: %s', $e->getMessage())); + } + $this->projectsFailed++; + } + } + } + + private function deleteSingleProject( + Client $client, + OutputInterface $output, + array $projectInfo, + bool $force + ): void { + if (isset($projectInfo['isDisabled']) && $projectInfo['isDisabled']) { + $output->writeln('project is disabled, skipping'); + $this->projectsDisabled++; + + return; + } + + if ($force) { + $client->deleteProject($projectInfo['id']); + + $projectDetail = $client->getDeletedProject($projectInfo['id']); + if (!$projectDetail['isDeleted']) { + $output->writeln( + sprintf('project "%s" deletion failed', $projectDetail['id']) + ); + $this->projectsFailed++; + + return; + } + $output->writeln( + sprintf('project "%s" has been deleted', $projectDetail['id']) + ); + + $this->projectsDeleted++; + } else { + $output->writeln( + sprintf('[DRY-RUN] would delete project "%s"', $projectInfo['id']) + ); + } + } + + private function printResult(OutputInterface $output): void + { + $output->writeln(sprintf(' %d projects disabled', $this->projectsDisabled)); + $output->writeln(sprintf(' %d projects deleted', $this->projectsDeleted)); + $output->writeln(sprintf(' %d projects failed', $this->projectsFailed)); + } +} From 4fb8d6aae1d36b96f77884f261632501a4755230 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Thu, 11 Sep 2025 15:47:15 +0200 Subject: [PATCH 02/10] readme --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index 2a7e1a6..27e264e 100644 --- a/README.md +++ b/README.md @@ -271,6 +271,29 @@ Run command: Use number of days or 0 as show to remove expiration completely. By default, it's dry-run. Override with `-f` parameter. +### Bulk Delete Projects + +Delete all projects specified by project IDs using the Manage API. By default, the command runs in dry-run mode and only reports what would be deleted. Use the `--force` flag to actually perform deletions. + +``` +php cli.php manage:delete-projects [-f|--force] +``` +Arguments: +- `token` (required): Manage API token. +- `url` (required): Stack URL, including `https://`. +- `projects` (required): Comma-separated list of project IDs to delete (e.g. `1,7,146`). + +Options: +- `--force` / `-f`: Actually delete the projects. Without this flag, the command only reports what would be deleted (dry-run). + +Behavior: +- For each project ID, checks if the project exists and is not already disabled. +- In dry-run mode, lists the projects that would be deleted. +- With `--force`, deletes each project and confirms deletion. +- Prints a summary of disabled, deleted, and failed projects. +- If run without `--force`, reminds the user that it was a dry run. + + ### Purge deleted projects Purge already deleted projects (remove residual metadata, optionally ignoring backend errors) using a Manage API token. From 92108f416944d252b2f22041ef296c4af48a7cd7 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Fri, 12 Sep 2025 10:59:48 +0200 Subject: [PATCH 03/10] better output for deleted projects --- src/Keboola/Console/Command/DeleteProjects.php | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Keboola/Console/Command/DeleteProjects.php b/src/Keboola/Console/Command/DeleteProjects.php index d39d312..22d5111 100644 --- a/src/Keboola/Console/Command/DeleteProjects.php +++ b/src/Keboola/Console/Command/DeleteProjects.php @@ -12,6 +12,8 @@ class DeleteProjects extends Command { + private int $projectNotFound = 0; + private int $projectsDisabled = 0; private int $projectsFailed = 0; private int $projectsDeleted = 0; @@ -74,11 +76,12 @@ private function deleteProjects( $this->deleteSingleProject($client, $output, $project, $force); } catch (ClientException $e) { if ($e->getCode() === 404) { - $output->writeln('not found'); + $output->writeln('not found - deleted already'); + $this->projectNotFound++; } else { $output->writeln(sprintf('error: %s', $e->getMessage())); + $this->projectsFailed++; } - $this->projectsFailed++; } } } @@ -125,5 +128,6 @@ private function printResult(OutputInterface $output): void $output->writeln(sprintf(' %d projects disabled', $this->projectsDisabled)); $output->writeln(sprintf(' %d projects deleted', $this->projectsDeleted)); $output->writeln(sprintf(' %d projects failed', $this->projectsFailed)); + $output->writeln(sprintf(' %d projects not found', $this->projectNotFound)); } } From b1a187e485a091cb25b3759a588f0105c82ecafe Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Fri, 12 Sep 2025 11:01:56 +0200 Subject: [PATCH 04/10] PAT-251 command to bulk unlink --- README.md | 23 +++++++ cli.php | 2 + .../Command/ForceUnlinkSharedBuckets.php | 62 +++++++++++++++++++ 3 files changed, 87 insertions(+) create mode 100644 src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php diff --git a/README.md b/README.md index 27e264e..9067a31 100644 --- a/README.md +++ b/README.md @@ -227,6 +227,28 @@ Command execution: cat data.csv | php cli.php storage:notify-projects MANAGETOKEN ``` + +### Force Unlink Shared and Linked Buckets + +List all buckets in the project and force-unlink those that are both shared and linked. By default, the command runs in dry-run mode and only reports what would be unlinked. Use the `--force` flag to actually perform the unlinking. + +``` +php cli.php storage:force-unlink-shared-buckets [--force|-f] +``` +Arguments: +- `storageToken` (required): Storage API token for the target project. +- `url` (required): Stack URL, including `https://`. + +Options: +- `--force` / `-f`: Actually perform the unlinking. Without this flag, the command only reports what would be unlinked (dry-run). + +Behavior: +- Lists all buckets in the project. +- For each bucket, checks if it is both shared and linked. +- In dry-run mode, lists the buckets that would be unlinked. +- With `--force`, unlinks each shared and linked bucket and confirms the action. +- Prints a summary of unlinked or would-be-unlinked buckets. + ### Mass enablement of dynamic backends for multiple projects Prerequisities: https://keboola.atlassian.net/wiki/spaces/KB/pages/2135982081/Enable+Dynamic+Backends#Enable-for-project @@ -475,6 +497,7 @@ Behavior: - If the user is a member, logs removal (and performs it if forced). - Prints final count of affected projects. + # License MIT licensed, see [LICENSE](./LICENSE) file. diff --git a/cli.php b/cli.php index 2a1ce88..6ed73cf 100644 --- a/cli.php +++ b/cli.php @@ -32,6 +32,7 @@ use Symfony\Component\Console\Application; use Keboola\Console\Command\SetDataRetention; use Keboola\Console\Command\UpdateDataRetention; +use Keboola\Console\Command\ForceUnlinkSharedBuckets; $application = new Application(); $application->add(new ProjectsAddFeature()); @@ -58,5 +59,6 @@ $application->add(new MassDeleteProjectWorkspaces()); $application->add(new UpdateDataRetention()); $application->add(new OrganizationResetWorkspacePasswords()); +$application->add(new ForceUnlinkSharedBuckets()); $application->add(new OrganizationsAddFeature()); $application->run(); diff --git a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php new file mode 100644 index 0000000..fa6a7a4 --- /dev/null +++ b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php @@ -0,0 +1,62 @@ +setName('storage:force-unlink-shared-buckets') + ->setDescription('List all buckets in the project and force-unlink those that are shared and linked.') + ->addArgument('storageToken', InputArgument::REQUIRED, 'Keboola Storage API token to use') + ->addArgument('url', InputArgument::REQUIRED, 'stack URL. Including https://') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Use [--force, -f] to actually unlink. Otherwise, dry-run.'); + } + + protected function execute(InputInterface $input, OutputInterface $output) + { + $token = $input->getArgument('storageToken'); + $url = $input->getArgument('url'); + $isForce = $input->getOption('force'); + $prefix = $isForce ? 'FORCE: ' : 'DRY-RUN: '; + + $client = new StorageApiClient([ + 'token' => $token, + 'url' => $url, + ]); + + $buckets = $client->listBuckets(['include' => 'linkedBuckets']); + + foreach ($buckets as $bucket) { + if (array_key_exists('linkedBy', $bucket)) { + foreach ($bucket['linkedBy'] as $link) { + if ($isForce) { + $client->forceUnlinkBucket($bucket['id'], $link['project']['id']); + } + $output->writeln( + sprintf( + '%s bucket "%s" force unlinked from project "%s" (%s)', + $prefix, + $bucket['id'], + $link['project']['name'], + $link['project']['id'], + ) + ); + } + } else { + $output->writeln(\sprintf('No linked buckets found for bucket "%s"', $bucket['id'])); + } + } + + return 0; + } +} + From 694234fe8ded9d38964ace614f1347be1ceedc47 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Fri, 12 Sep 2025 11:03:34 +0200 Subject: [PATCH 05/10] cs --- src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php | 1 - 1 file changed, 1 deletion(-) diff --git a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php index fa6a7a4..099376a 100644 --- a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php +++ b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php @@ -59,4 +59,3 @@ protected function execute(InputInterface $input, OutputInterface $output) return 0; } } - From 9f3dc172e1f9682f561b4199ff7e2424e1a81488 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Semmler?= <13363655+jirkasemmler@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:15:26 +0200 Subject: [PATCH 06/10] Update src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php index 099376a..31373ee 100644 --- a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php +++ b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php @@ -15,7 +15,7 @@ protected function configure() { $this ->setName('storage:force-unlink-shared-buckets') - ->setDescription('List all buckets in the project and force-unlink those that are shared and linked.') + ->setDescription('List all buckets in the project and force-unlink those that are shared BY this project and linked TO other projects.') ->addArgument('storageToken', InputArgument::REQUIRED, 'Keboola Storage API token to use') ->addArgument('url', InputArgument::REQUIRED, 'stack URL. Including https://') ->addOption('force', 'f', InputOption::VALUE_NONE, 'Use [--force, -f] to actually unlink. Otherwise, dry-run.'); From 704be4130d21ca7de37091f5f07e3291e4a754c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ji=C5=99=C3=AD=20Semmler?= <13363655+jirkasemmler@users.noreply.github.com> Date: Fri, 12 Sep 2025 11:15:41 +0200 Subject: [PATCH 07/10] Update src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php index 31373ee..9fcb402 100644 --- a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php +++ b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php @@ -52,7 +52,7 @@ protected function execute(InputInterface $input, OutputInterface $output) ); } } else { - $output->writeln(\sprintf('No linked buckets found for bucket "%s"', $bucket['id'])); + $output->writeln(sprintf('No linked buckets found for bucket "%s"', $bucket['id'])); } } From ebc651e3529a42220497864604ee0d4e7ec15e8b Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Wed, 3 Jun 2026 11:44:53 +0200 Subject: [PATCH 08/10] Address Copilot review on DeleteProjects - Return int instead of ?int from execute() (always returns an exit code). - Validate project IDs: trim, report any non-numeric entries and abort with exit code 1 instead of silently dropping them. - Fix invalid tag to so the failure message renders correctly. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Keboola/Console/Command/DeleteProjects.php | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src/Keboola/Console/Command/DeleteProjects.php b/src/Keboola/Console/Command/DeleteProjects.php index 22d5111..faf5242 100644 --- a/src/Keboola/Console/Command/DeleteProjects.php +++ b/src/Keboola/Console/Command/DeleteProjects.php @@ -29,7 +29,7 @@ protected function configure(): void ->addOption('force', 'f', InputOption::VALUE_NONE, 'Will actually do the work, otherwise it\'s dry run'); } - public function execute(InputInterface $input, OutputInterface $output): ?int + public function execute(InputInterface $input, OutputInterface $output): int { $apiToken = $input->getArgument('token'); $apiUrl = $input->getArgument('url'); @@ -39,7 +39,19 @@ public function execute(InputInterface $input, OutputInterface $output): ?int $client = $this->createClient($apiUrl, $apiToken); - $projectIds = array_filter(explode(',', $projects), 'is_numeric'); + $projectIdStrings = array_map('trim', explode(',', $projects)); + $invalidProjectIds = array_filter($projectIdStrings, function ($id) { + return !is_numeric($id); + }); + if (!empty($invalidProjectIds)) { + $output->writeln( + sprintf('Invalid project IDs detected: %s', implode(', ', $invalidProjectIds)) + ); + $output->writeln('Please check your input for typos or formatting issues. Only numeric project IDs are allowed.'); + + return 1; + } + $projectIds = array_map('intval', $projectIdStrings); $this->deleteProjects($client, $output, $projectIds, $force); $output->writeln(''); @@ -105,7 +117,7 @@ private function deleteSingleProject( $projectDetail = $client->getDeletedProject($projectInfo['id']); if (!$projectDetail['isDeleted']) { $output->writeln( - sprintf('project "%s" deletion failed', $projectDetail['id']) + sprintf('project "%s" deletion failed', $projectDetail['id']) ); $this->projectsFailed++; From 68e437336361038ce2181ec2076a5a088b6fa1ff Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Wed, 3 Jun 2026 12:07:51 +0200 Subject: [PATCH 09/10] Fix phpstan level 9 errors on the branch These errors predate this PR (the branch went red after being rebased onto a stricter main) and were blocking CI: - DeleteProjects: assert string arguments, annotate array param types and narrow the project id before sprintf. - ForceUnlinkSharedBuckets: add int return type and assert string arguments. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/Keboola/Console/Command/DeleteProjects.php | 13 ++++++++++++- .../Console/Command/ForceUnlinkSharedBuckets.php | 4 +++- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/src/Keboola/Console/Command/DeleteProjects.php b/src/Keboola/Console/Command/DeleteProjects.php index faf5242..2854497 100644 --- a/src/Keboola/Console/Command/DeleteProjects.php +++ b/src/Keboola/Console/Command/DeleteProjects.php @@ -32,8 +32,11 @@ protected function configure(): void public function execute(InputInterface $input, OutputInterface $output): int { $apiToken = $input->getArgument('token'); + assert(is_string($apiToken)); $apiUrl = $input->getArgument('url'); + assert(is_string($apiUrl)); $projects = $input->getArgument('projects'); + assert(is_string($projects)); $force = (bool) $input->getOption('force'); @@ -74,6 +77,9 @@ private function createClient(string $host, string $token): Client ]); } + /** + * @param int[] $projectIds + */ private function deleteProjects( Client $client, OutputInterface $output, @@ -98,6 +104,9 @@ private function deleteProjects( } } + /** + * @param array $projectInfo + */ private function deleteSingleProject( Client $client, OutputInterface $output, @@ -129,8 +138,10 @@ private function deleteSingleProject( $this->projectsDeleted++; } else { + $projectId = $projectInfo['id']; + assert(is_string($projectId) || is_int($projectId)); $output->writeln( - sprintf('[DRY-RUN] would delete project "%s"', $projectInfo['id']) + sprintf('[DRY-RUN] would delete project "%s"', $projectId) ); } } diff --git a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php index 9fcb402..9eac97a 100644 --- a/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php +++ b/src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php @@ -21,10 +21,12 @@ protected function configure() ->addOption('force', 'f', InputOption::VALUE_NONE, 'Use [--force, -f] to actually unlink. Otherwise, dry-run.'); } - protected function execute(InputInterface $input, OutputInterface $output) + protected function execute(InputInterface $input, OutputInterface $output): int { $token = $input->getArgument('storageToken'); + assert(is_string($token)); $url = $input->getArgument('url'); + assert(is_string($url)); $isForce = $input->getOption('force'); $prefix = $isForce ? 'FORCE: ' : 'DRY-RUN: '; From 343cfdc51cb155c85741bc59a554975fdf322c0d Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Wed, 3 Jun 2026 13:45:04 +0200 Subject: [PATCH 10/10] cli --- cli.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/cli.php b/cli.php index 6ed73cf..030535b 100644 --- a/cli.php +++ b/cli.php @@ -61,4 +61,6 @@ $application->add(new OrganizationResetWorkspacePasswords()); $application->add(new ForceUnlinkSharedBuckets()); $application->add(new OrganizationsAddFeature()); +$application->add(new DeleteProjects()); +$application->add(new DeleteStorageBackend()); $application->run();