Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 23 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] <token> <url> <projects>
```
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.

Expand Down
2 changes: 2 additions & 0 deletions cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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());
Expand Down
133 changes: 133 additions & 0 deletions src/Keboola/Console/Command/DeleteProjects.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
<?php

namespace Keboola\Console\Command;

use Keboola\ManageApi\Client;
use Keboola\ManageApi\ClientException;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Input\InputOption;
use Symfony\Component\Console\Output\OutputInterface;

class DeleteProjects extends Command
{
private int $projectNotFound = 0;

private int $projectsDisabled = 0;
private int $projectsFailed = 0;
private int $projectsDeleted = 0;

protected function configure(): void
{
$this
->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
Copy link

Copilot AI Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The return type should be int instead of ?int. Symfony Console commands should always return an integer exit code, and this method always returns 0.

Suggested change
public function execute(InputInterface $input, OutputInterface $output): ?int
public function execute(InputInterface $input, OutputInterface $output): int

Copilot uses AI. Check for mistakes.
{
$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');
Copy link

Copilot AI Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filtering silently removes non-numeric values without informing the user. Consider validating the input and providing feedback about invalid project IDs to help users identify typos or formatting issues.

Suggested change
$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('<error>Invalid project IDs detected: ' . implode(', ', $invalidProjectIds) . '</error>');
$output->writeln('Please check your input for typos or formatting issues. Only numeric project IDs are allowed.');
return 1;
}
$projectIds = array_map('intval', $projectIdStrings);

Copilot uses AI. Check for mistakes.
$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 <comment>dry-run</comment> 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 <comment>%s</comment>: ', $projectId));

try {
$project = $client->getProject($projectId);
$this->deleteSingleProject($client, $output, $project, $force);
} catch (ClientException $e) {
if ($e->getCode() === 404) {
$output->writeln('<info>not found - deleted already</info>');
$this->projectNotFound++;
} else {
$output->writeln(sprintf('<error>error</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, <comment>skipping</comment>');
$this->projectsDisabled++;

return;
}

if ($force) {
$client->deleteProject($projectInfo['id']);

$projectDetail = $client->getDeletedProject($projectInfo['id']);
if (!$projectDetail['isDeleted']) {
$output->writeln(
sprintf('<err>project "%s" deletion failed</err>', $projectDetail['id'])
Copy link

Copilot AI Sep 12, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tag <err> should be <error> to match Symfony Console's standard error formatting tags.

Suggested change
sprintf('<err>project "%s" deletion failed</err>', $projectDetail['id'])
sprintf('<error>project "%s" deletion failed</error>', $projectDetail['id'])

Copilot uses AI. Check for mistakes.
);
$this->projectsFailed++;

return;
}
$output->writeln(
sprintf('<info>project "%s" has been deleted</info>', $projectDetail['id'])
);

$this->projectsDeleted++;
} else {
$output->writeln(
sprintf('<info>[DRY-RUN] would delete project "%s"</info>', $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));
$output->writeln(sprintf(' %d projects not found', $this->projectNotFound));
}
}