Skip to content
Closed
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
22 changes: 22 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,28 @@ cat data.csv | php cli.php storage:notify-projects MANAGETOKEN
```


### List external buckets on a stack
Read-only. Walks every project on the stack and prints a CSV of all external buckets (buckets with
`hasExternalSchema`), including whether each one is read-only, whether it is linked from elsewhere,
and whether it has `KBC.description` metadata set.

```
php cli.php manage:list-external-buckets <manage-token> [<hostname-suffix>]
```
Arguments:
- manage-token (required) Manage API token (super admin); used to create short-lived project Storage tokens.
- hostname-suffix (optional, default: keboola.com) Connection host suffix (e.g. eu-central-1.keboola.com).

Behavior:
- Iterates maintainers -> organizations -> projects, de-duplicating projects that appear more than once.
- Creates a 15-minute Storage token per project and always drops it afterwards, including when
listing fails.
- Projects the token cannot reach are reported as comment lines and counted as skipped, so the run
continues over the rest of the stack.
- Writes CSV to stdout with the header
`projectId,projectName,bucketId,created,isReadOnly,linked,hasDescription,description`, so it can be
redirected straight to a file. Progress and summary lines are prefixed with `#`.

### 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.
Expand Down
2 changes: 2 additions & 0 deletions cli.php
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
use Keboola\Console\Command\DeleteOwnerlessWorkspaces;
use Keboola\Console\Command\DescribeOrganizationWorkspaces;
use Keboola\Console\Command\LineageEventsExport;
use Keboola\Console\Command\ListExternalBuckets;
use Keboola\Console\Command\MassDeleteProjectWorkspaces;
use Keboola\Console\Command\MassProjectEnableDynamicBackends;
use Keboola\Console\Command\MassProjectExtendExpiration;
Expand Down Expand Up @@ -64,6 +65,7 @@
$application->add(new UpdateDataRetention());
$application->add(new OrganizationResetWorkspacePasswords());
$application->add(new ForceUnlinkSharedBuckets());
$application->add(new ListExternalBuckets());
$application->add(new OrganizationsAddFeature());
$application->add(new DeleteProjects());
$application->add(new DeleteStorageBackend());
Expand Down
143 changes: 143 additions & 0 deletions src/Keboola/Console/Command/ListExternalBuckets.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
<?php

declare(strict_types=1);

namespace Keboola\Console\Command;

use Keboola\ManageApi\Client as ManageClient;
use Keboola\ServiceClient\ServiceClient;
use Keboola\StorageApi\Client as StorageApiClient;
use Keboola\StorageApi\Tokens;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputArgument;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

class ListExternalBuckets extends Command
{
private const ARGUMENT_MANAGE_TOKEN = 'manage-token';
private const ARGUMENT_HOSTNAME_SUFFIX = 'hostname-suffix';

protected function configure(): void
{
$this
->setName('manage:list-external-buckets')
->setDescription(
'Read-only: list all external buckets (hasExternalSchema) across all projects of a stack '
. 'with created date, isReadOnly flag and whether KBC.description metadata is set.'
)
->addArgument(
self::ARGUMENT_MANAGE_TOKEN,
InputArgument::REQUIRED,
'Manage API token (super admin) used to create short-lived project storage tokens.'
)
->addArgument(
self::ARGUMENT_HOSTNAME_SUFFIX,
InputArgument::OPTIONAL,
'Keboola Connection Hostname Suffix',
'keboola.com'
);
}

protected function execute(InputInterface $input, OutputInterface $output): int
{
$manageToken = $input->getArgument(self::ARGUMENT_MANAGE_TOKEN);
assert(is_string($manageToken));
$hostnameSuffix = $input->getArgument(self::ARGUMENT_HOSTNAME_SUFFIX);
assert(is_string($hostnameSuffix));
assert($hostnameSuffix !== '');

$serviceClient = new ServiceClient($hostnameSuffix);
$connectionUrl = $serviceClient->getConnectionServiceUrl();
$manageClient = new ManageClient(['token' => $manageToken, 'url' => $connectionUrl]);

$output->writeln('projectId,projectName,bucketId,created,isReadOnly,linked,hasDescription,description');

$externalBucketsFound = 0;
$projectsChecked = 0;
$projectsSkipped = 0;
$seenProjects = [];

foreach ($manageClient->listMaintainers() as $maintainer) {
foreach ($manageClient->listMaintainerOrganizations($maintainer['id']) as $organization) {
foreach ($manageClient->listOrganizationProjects($organization['id']) as $project) {
$projectId = (int) $project['id'];
if (isset($seenProjects[$projectId])) {
continue;
}
$seenProjects[$projectId] = true;

try {
$storageToken = $manageClient->createProjectStorageToken(
$projectId,
[
'description' => 'List external buckets (read-only audit)',
'expiresIn' => 900,
'canManageBuckets' => true,
]
);
} catch (\Throwable $e) {
$output->writeln(sprintf(
'<error># Access denied or error for project "%s" ("%s"): %s</error>',
$projectId,
$project['name'],
$e->getMessage(),
));
$projectsSkipped++;
continue;
}
assert(is_string($storageToken['token']));
$projectsChecked++;

$storageClient = new StorageApiClient([
'token' => $storageToken['token'],
'url' => $connectionUrl,
]);

try {
$buckets = $storageClient->listBuckets(['include' => 'metadata']);
foreach ($buckets as $bucket) {
if (($bucket['hasExternalSchema'] ?? false) !== true) {
continue;
}
$externalBucketsFound++;

$description = null;
foreach ($bucket['metadata'] ?? [] as $metadata) {
if ($metadata['key'] === 'KBC.description') {
$description = (string) $metadata['value'];
break;
}
}

$output->writeln(sprintf(
'%d,"%s",%s,%s,%s,%s,%s,"%s"',
$projectId,
str_replace('"', '""', (string) $project['name']),
$bucket['id'],
$bucket['created'],
($bucket['isReadOnly'] ?? false) ? 'READ_ONLY' : 'writable',
isset($bucket['sourceBucket']) ? 'linked' : 'own',
$description !== null ? 'yes' : 'no',
str_replace('"', '""', (string) ($description ?? '')),
));
}
} finally {
$tokensClient = new Tokens($storageClient);
assert(is_scalar($storageToken['id']));
$tokensClient->dropToken((int) $storageToken['id']);
}
}
}
}

$output->writeln(sprintf(
'# Done: %d external buckets in %d projects checked (%d projects skipped).',
$externalBucketsFound,
$projectsChecked,
$projectsSkipped,
));

return 0;
}
}