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
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -195,6 +195,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] <storageToken> <url>
```
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

Expand Down Expand Up @@ -239,6 +261,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 Expand Up @@ -410,6 +455,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.
4 changes: 4 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 All @@ -28,6 +29,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());
Expand All @@ -40,6 +42,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 All @@ -53,4 +56,5 @@
$application->add(new MassDeleteProjectWorkspaces());
$application->add(new UpdateDataRetention());
$application->add(new OrganizationResetWorkspacePasswords());
$application->add(new ForceUnlinkSharedBuckets());
$application->run();
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));
}
}
61 changes: 61 additions & 0 deletions src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
<?php

namespace Keboola\Console\Command;

use Keboola\StorageApi\Client as StorageApiClient;
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 ForceUnlinkSharedBuckets extends Command
{
protected function configure()
{
$this
->setName('storage:force-unlink-shared-buckets')
->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.');
}

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;
}
}
Loading