From 0df98f630cfa89e3e5e1f59cd91f5680213e900b Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Thu, 11 Sep 2025 15:28:14 +0200 Subject: [PATCH 1/3] Init new command to mass project deletion --- cli.php | 2 + .../Console/Command/DeleteProjects.php | 129 ++++++++++++++++++ 2 files changed, 131 insertions(+) create mode 100644 src/Keboola/Console/Command/DeleteProjects.php diff --git a/cli.php b/cli.php index eecf6a7..09469b3 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\DeleteOrganizationOrphanedWorkspaces; use Keboola\Console\Command\DeleteOrphanedWorkspaces; use Keboola\Console\Command\DeleteOwnerlessWorkspaces; @@ -40,6 +41,7 @@ $application->add(new AddFeature()); $application->add(new AllStacksIterator()); $application->add(new LineageEventsExport()); +$application->add(new DeleteProjects()); $application->add(new QueueMassTerminateJobs()); $application->add(new DeleteOrphanedWorkspaces()); $application->add(new DeleteOrganizationOrphanedWorkspaces()); 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 69e541342f2b0b29609f507c8f6405345d359fde Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Thu, 11 Sep 2025 15:47:15 +0200 Subject: [PATCH 2/3] readme --- README.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/README.md b/README.md index e854e60..77ec19c 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,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 and a CSV piped via STDIN. From 6f54035434f3f2dc2d7a63683b19793943486ae9 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Fri, 12 Sep 2025 10:59:48 +0200 Subject: [PATCH 3/3] 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)); } }