Skip to content
Merged
46 changes: 46 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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] <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 @@ -271,6 +293,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.

Expand Down Expand Up @@ -452,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.
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\DeleteStorageBackend;
use Keboola\Console\Command\DeleteOrganizationOrphanedWorkspaces;
use Keboola\Console\Command\DeleteOrganizationOwnerlessWorkspaces;
Expand All @@ -31,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());
Expand All @@ -57,6 +59,8 @@
$application->add(new MassDeleteProjectWorkspaces());
$application->add(new UpdateDataRetention());
$application->add(new OrganizationResetWorkspacePasswords());
$application->add(new ForceUnlinkSharedBuckets());
$application->add(new OrganizationsAddFeature());
$application->add(new DeleteProjects());
$application->add(new DeleteStorageBackend());
$application->run();
156 changes: 156 additions & 0 deletions src/Keboola/Console/Command/DeleteProjects.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,156 @@
<?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
{
$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');

$client = $this->createClient($apiUrl, $apiToken);

$projectIdStrings = array_map('trim', explode(',', $projects));
$invalidProjectIds = array_filter($projectIdStrings, function ($id) {
return !is_numeric($id);
});
if (!empty($invalidProjectIds)) {
$output->writeln(
sprintf('<error>Invalid project IDs detected: %s</error>', 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('');

$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,
]);
}

/**
* @param int[] $projectIds
*/
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++;
}
}
}
}

/**
* @param array<string, mixed> $projectInfo
*/
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('<error>project "%s" deletion failed</error>', $projectDetail['id'])
);
$this->projectsFailed++;

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

$this->projectsDeleted++;
} else {
$projectId = $projectInfo['id'];
assert(is_string($projectId) || is_int($projectId));
$output->writeln(
sprintf('<info>[DRY-RUN] would delete project "%s"</info>', $projectId)
);
}
}

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));
}
}
63 changes: 63 additions & 0 deletions src/Keboola/Console/Command/ForceUnlinkSharedBuckets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
<?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): 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: ';

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