From 55e36b0153e72b13dde287f77ba4bf6e748955d6 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Wed, 8 Jul 2026 23:52:31 +0200 Subject: [PATCH] Add workspace state check and per-workspace delete commands MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit manage:check-project-workspaces-state — read-only classifier: for a CSV of projectId,schema[,componentId,configId] it checks the live API (all dev branches) and reports whether each workspace is live, its configuration is in trash, or fully purged, with a suggested cleanup action per row. manage:delete-project-workspaces-by-id — deletes single workspaces by id (keeps the parent configuration and its other workspaces), or with --with-configuration the whole configuration (trash + purge). Dry-run by default; refuses non-password-login (key-pair) workspaces unless --any-login-type; optional expectedSchema column cross-check; with --with-configuration refuses configurations owning more than the one listed workspace. Both mint short-lived per-project storage tokens from a manage token, so no interactive token pasting. Built for LEGACY_SERVICE workspace-user cleanups (DMD-1565). Co-Authored-By: Claude Fable 5 --- cli.php | 4 + .../Command/CheckProjectWorkspacesState.php | 314 ++++++++++++++ .../Command/DeleteProjectWorkspacesById.php | 399 ++++++++++++++++++ 3 files changed, 717 insertions(+) create mode 100644 src/Keboola/Console/Command/CheckProjectWorkspacesState.php create mode 100644 src/Keboola/Console/Command/DeleteProjectWorkspacesById.php diff --git a/cli.php b/cli.php index 030535b..75a1575 100644 --- a/cli.php +++ b/cli.php @@ -14,6 +14,8 @@ use Keboola\Console\Command\DeleteOwnerlessWorkspaces; use Keboola\Console\Command\DescribeOrganizationWorkspaces; use Keboola\Console\Command\LineageEventsExport; +use Keboola\Console\Command\CheckProjectWorkspacesState; +use Keboola\Console\Command\DeleteProjectWorkspacesById; use Keboola\Console\Command\MassDeleteProjectWorkspaces; use Keboola\Console\Command\MassProjectEnableDynamicBackends; use Keboola\Console\Command\MassProjectExtendExpiration; @@ -57,6 +59,8 @@ $application->add(new ReactivateSchedules()); $application->add(new DescribeOrganizationWorkspaces()); $application->add(new MassDeleteProjectWorkspaces()); +$application->add(new DeleteProjectWorkspacesById()); +$application->add(new CheckProjectWorkspacesState()); $application->add(new UpdateDataRetention()); $application->add(new OrganizationResetWorkspacePasswords()); $application->add(new ForceUnlinkSharedBuckets()); diff --git a/src/Keboola/Console/Command/CheckProjectWorkspacesState.php b/src/Keboola/Console/Command/CheckProjectWorkspacesState.php new file mode 100644 index 0000000..5b615ae --- /dev/null +++ b/src/Keboola/Console/Command/CheckProjectWorkspacesState.php @@ -0,0 +1,314 @@ + 'manage:delete-project-workspaces-by-id', + self::STATUS_CONFIG_LIVE_WORKSPACE_GONE => 'investigate: config lives without this workspace, backend user is likely orphaned', + self::STATUS_CONFIG_IN_TRASH => 'purge configuration from trash (deleteConfiguration on trashed config)', + self::STATUS_PURGED_OR_ORPHAN => 'drop backend user (storage:workspace:drop-failed-workspaces-from-metadata or manual)', + self::STATUS_NOT_LIVE_NO_CONFIG_REF => 'investigate: not live and no componentId/configId reference to check', + self::STATUS_ACCESS_DENIED => 'grant manage token access to project and re-run', + ]; + + protected function configure(): void + { + $this + ->setName('manage:check-project-workspaces-state') + ->setDescription( + 'Read-only check of workspaces state: live / config in trash / purged. ' + . 'Classifies each row and suggests the matching cleanup action.' + ) + ->addArgument( + self::ARGUMENT_MANAGE_TOKEN, + InputArgument::REQUIRED, + 'Manage API token (super admin) used to create short-lived project storage tokens.' + ) + ->addArgument( + self::ARGUMENT_SOURCE_FILE, + InputArgument::REQUIRED, + 'Source csv with "projectId,workspaceSchema[,componentId,configurationId]" columns and no header.' + ) + ->addArgument( + self::ARGUMENT_OUTPUT_FILE, + InputArgument::REQUIRED, + 'File to output the csv report to.' + ) + ->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)); + $sourceFile = $input->getArgument(self::ARGUMENT_SOURCE_FILE); + assert(is_string($sourceFile)); + $outputFile = $input->getArgument(self::ARGUMENT_OUTPUT_FILE); + assert(is_string($outputFile)); + $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]); + + /** @var array> $map */ + $map = []; + $totalRows = 0; + $csv = new CsvFile($sourceFile); + foreach ($csv as $line) { + assert(is_array($line)); + if (count($line) !== 2 && count($line) !== 4) { + throw new InvalidArgumentException( + 'File must contain two or four columns (projectId,workspaceSchema[,componentId,configurationId]).' + ); + } + $projectId = $line[0]; + $schema = $line[1]; + assert(is_string($projectId) || is_numeric($projectId)); + assert(is_string($schema)); + if (!is_numeric($projectId)) { + throw new InvalidArgumentException(sprintf('Project id "%s" is not numeric.', $projectId)); + } + if (!str_starts_with($schema, 'WORKSPACE_')) { + throw new InvalidArgumentException(sprintf('Workspace schema "%s" does not start with "WORKSPACE_".', $schema)); + } + $componentId = null; + $configurationId = null; + if (count($line) === 4) { + assert(is_string($line[2])); + assert(is_string($line[3]) || is_numeric($line[3])); + $componentId = $line[2] !== '' ? $line[2] : null; + $configurationId = (string) $line[3] !== '' ? (string) $line[3] : null; + } + $map[(string) $projectId][] = [ + 'schema' => $schema, + 'componentId' => $componentId, + 'configurationId' => $configurationId, + ]; + $totalRows++; + } + $output->writeln(sprintf('Loaded %d workspaces in %d projects from "%s".', $totalRows, count($map), $sourceFile)); + + $report = new CsvFile($outputFile); + $report->writeRow([ + 'projectId', + 'workspaceSchema', + 'componentId', + 'configurationId', + 'status', + 'suggestedAction', + 'workspaceId', + 'branchId', + 'branchName', + 'loginType', + 'liveComponentId', + 'liveConfigurationId', + 'note', + ]); + + /** @var array $statusCounts */ + $statusCounts = []; + + foreach ($map as $projectId => $rows) { + $projectId = (string) $projectId; + $output->writeln(sprintf('Checking project "%s" (%d workspaces).', $projectId, count($rows))); + try { + $storageToken = $manageClient->createProjectStorageToken( + (int) $projectId, + [ + 'description' => 'Read-only workspace state check', + 'expiresIn' => 1800, + // reading component configurations (incl. trash listing) is not + // allowed for a minimal token + 'canManageBuckets' => true, + ] + ); + } catch (\Throwable $e) { + if ($e->getCode() === 403) { + $output->writeln(sprintf('Access denied to project "%s".', $projectId)); + foreach ($rows as $row) { + $this->writeReportRow($report, $statusCounts, $projectId, $row, self::STATUS_ACCESS_DENIED); + } + continue; + } + throw $e; + } + assert(is_string($storageToken['token'])); + + $storageClient = new StorageApiClient([ + 'token' => $storageToken['token'], + 'url' => $connectionUrl, + ]); + + $componentIds = array_values(array_unique(array_filter(array_map( + fn(array $row): ?string => $row['componentId'], + $rows, + )))); + + // collect live workspaces (by schema), live configs and trashed configs across all branches + /** @var array $liveWorkspacesBySchema */ + $liveWorkspacesBySchema = []; + /** @var array $liveConfigs */ + $liveConfigs = []; + /** @var array $trashedConfigs */ + $trashedConfigs = []; + + $devBranches = new DevBranches($storageClient); + foreach ($devBranches->listBranches() as $branch) { + $branchId = $branch['id']; + assert(is_int($branchId)); + $branchName = $branch['name']; + assert(is_string($branchName)); + $branchClient = new BranchAwareClient($branchId, [ + 'token' => $storageToken['token'], + 'url' => $connectionUrl, + ]); + + $workspacesClient = new Workspaces($branchClient); + foreach ($workspacesClient->listWorkspaces() as $workspace) { + $schema = $workspace['connection']['schema'] ?? $workspace['name'] ?? ''; + if ($schema === '') { + continue; + } + $liveWorkspacesBySchema[$schema] = [ + 'workspaceId' => (string) $workspace['id'], + 'branchId' => $branchId, + 'branchName' => $branchName, + 'loginType' => $workspace['connection']['loginType'] ?? '', + 'componentId' => $workspace['component'] ?? '', + 'configurationId' => $workspace['configurationId'] ?? '', + ]; + } + + $components = new Components($branchClient); + foreach ($componentIds as $componentId) { + foreach ([false, true] as $isDeleted) { + $configurations = $components->listComponentConfigurations( + (new ListComponentConfigurationsOptions()) + ->setComponentId($componentId) + ->setIsDeleted($isDeleted) + ); + assert(is_array($configurations)); + foreach ($configurations as $configuration) { + assert(is_array($configuration)); + assert(is_scalar($configuration['id'])); + $key = $componentId . '/' . (string) $configuration['id']; + if ($isDeleted) { + $trashedConfigs[$key] = true; + } else { + $liveConfigs[$key] = true; + } + } + } + } + } + + foreach ($rows as $row) { + if (isset($liveWorkspacesBySchema[$row['schema']])) { + $live = $liveWorkspacesBySchema[$row['schema']]; + $note = ''; + if ($row['configurationId'] !== null && $row['configurationId'] !== $live['configurationId']) { + $note = sprintf('configurationId differs from expected "%s"', $row['configurationId']); + } + $this->writeReportRow($report, $statusCounts, $projectId, $row, self::STATUS_LIVE, $live, $note); + continue; + } + if ($row['componentId'] === null || $row['configurationId'] === null) { + $this->writeReportRow($report, $statusCounts, $projectId, $row, self::STATUS_NOT_LIVE_NO_CONFIG_REF); + continue; + } + $key = $row['componentId'] . '/' . $row['configurationId']; + if (isset($trashedConfigs[$key])) { + $this->writeReportRow($report, $statusCounts, $projectId, $row, self::STATUS_CONFIG_IN_TRASH); + } elseif (isset($liveConfigs[$key])) { + $this->writeReportRow($report, $statusCounts, $projectId, $row, self::STATUS_CONFIG_LIVE_WORKSPACE_GONE); + } else { + $this->writeReportRow($report, $statusCounts, $projectId, $row, self::STATUS_PURGED_OR_ORPHAN); + } + } + + $tokensClient = new Tokens($storageClient); + assert(is_scalar($storageToken['id'])); + $tokensClient->dropToken((int) $storageToken['id']); + } + + $output->writeln(sprintf('Report of %d workspaces written to "%s":', $totalRows, $outputFile)); + ksort($statusCounts); + foreach ($statusCounts as $status => $count) { + $output->writeln(sprintf(' - %s: %d (%s)', $status, $count, self::SUGGESTED_ACTION[$status])); + } + + return 0; + } + + /** + * @param array $statusCounts + * @param array{schema: string, componentId: string|null, configurationId: string|null} $row + * @param array{workspaceId: string, branchId: int, branchName: string, loginType: string, componentId: string, configurationId: string}|null $live + */ + private function writeReportRow( + CsvFile $report, + array &$statusCounts, + string $projectId, + array $row, + string $status, + ?array $live = null, + string $note = '' + ): void { + $statusCounts[$status] = ($statusCounts[$status] ?? 0) + 1; + $report->writeRow([ + $projectId, + $row['schema'], + $row['componentId'] ?? '', + $row['configurationId'] ?? '', + $status, + self::SUGGESTED_ACTION[$status], + $live['workspaceId'] ?? '', + $live !== null ? (string) $live['branchId'] : '', + $live['branchName'] ?? '', + $live['loginType'] ?? '', + $live['componentId'] ?? '', + $live['configurationId'] ?? '', + $note, + ]); + } +} diff --git a/src/Keboola/Console/Command/DeleteProjectWorkspacesById.php b/src/Keboola/Console/Command/DeleteProjectWorkspacesById.php new file mode 100644 index 0000000..ceb56c1 --- /dev/null +++ b/src/Keboola/Console/Command/DeleteProjectWorkspacesById.php @@ -0,0 +1,399 @@ +setName('manage:delete-project-workspaces-by-id') + ->setDescription( + 'Delete single workspaces by their id (keeps the parent configuration and its other workspaces). ' + . 'By default only workspaces with password login (LEGACY_SERVICE) are deleted.' + ) + ->addArgument( + self::ARGUMENT_MANAGE_TOKEN, + InputArgument::REQUIRED, + 'Manage API token (super admin) used to create short-lived project storage tokens.' + ) + ->addArgument( + self::ARGUMENT_SOURCE_FILE, + InputArgument::REQUIRED, + 'Source csv with "projectId,workspaceId[,expectedSchema]" columns and no header. ' + . 'When expectedSchema is present the workspace schema must match it, otherwise the row is skipped.' + ) + ->addArgument( + self::ARGUMENT_HOSTNAME_SUFFIX, + InputArgument::OPTIONAL, + 'Keboola Connection Hostname Suffix', + 'keboola.com' + ) + ->addOption(self::OPTION_FORCE, 'f', InputOption::VALUE_NONE, 'Write changes') + ->addOption( + self::OPTION_ANY_LOGIN_TYPE, + null, + InputOption::VALUE_NONE, + 'Also delete workspaces whose login type is not a password login (key-pair etc.). USE WITH CARE.' + ) + ->addOption( + self::OPTION_WITH_CONFIGURATION, + null, + InputOption::VALUE_NONE, + 'Delete the whole parent configuration (trash + purge) instead of just the workspace. ' + . 'Refuses configurations that have any other workspace than the one listed in the csv.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $manageToken = $input->getArgument(self::ARGUMENT_MANAGE_TOKEN); + assert(is_string($manageToken)); + $sourceFile = $input->getArgument(self::ARGUMENT_SOURCE_FILE); + assert(is_string($sourceFile)); + $hostnameSuffix = $input->getArgument(self::ARGUMENT_HOSTNAME_SUFFIX); + assert(is_string($hostnameSuffix)); + assert($hostnameSuffix !== ''); + $force = (bool) $input->getOption(self::OPTION_FORCE); + $anyLoginType = (bool) $input->getOption(self::OPTION_ANY_LOGIN_TYPE); + $withConfiguration = (bool) $input->getOption(self::OPTION_WITH_CONFIGURATION); + + $serviceClient = new ServiceClient($hostnameSuffix); + $connectionUrl = $serviceClient->getConnectionServiceUrl(); + $manageClient = new ManageClient(['token' => $manageToken, 'url' => $connectionUrl]); + + $output->writeln(sprintf('Fetching workspaces to delete from "%s"', $sourceFile)); + $output->writeln($force + ? 'Force option is set, doing it for real' + : 'This is just a dry-run, nothing will be actually deleted'); + + /** @var array> $map */ + $map = []; + $totalRows = 0; + $csv = new CsvFile($sourceFile); + foreach ($csv as $line) { + assert(is_array($line)); + if (count($line) !== 2 && count($line) !== 3) { + throw new InvalidArgumentException('File must contain two or three columns (projectId,workspaceId[,expectedSchema]).'); + } + $projectId = $line[0]; + $workspaceId = $line[1]; + $expectedSchema = count($line) === 3 ? $line[2] : null; + assert(is_string($projectId) || is_numeric($projectId)); + assert(is_string($workspaceId) || is_numeric($workspaceId)); + assert($expectedSchema === null || is_string($expectedSchema)); + if (!is_numeric($projectId)) { + throw new InvalidArgumentException(sprintf('Project id "%s" is not numeric.', $projectId)); + } + if (!is_numeric($workspaceId)) { + throw new InvalidArgumentException(sprintf('Workspace id "%s" is not numeric.', $workspaceId)); + } + if ($expectedSchema !== null && !str_starts_with($expectedSchema, 'WORKSPACE_')) { + throw new InvalidArgumentException(sprintf('Expected schema "%s" does not start with "WORKSPACE_".', $expectedSchema)); + } + $map[(string) $projectId][] = [ + 'workspaceId' => (string) $workspaceId, + 'expectedSchema' => $expectedSchema, + ]; + $totalRows++; + } + $output->writeln(sprintf('Loaded %d workspaces in %d projects.', $totalRows, count($map))); + + $deleted = 0; + $failed = 0; + $skippedLoginType = 0; + $skippedSchemaMismatch = 0; + $skippedConfigGuard = 0; + $notFound = []; + + foreach ($map as $projectId => $rows) { + $output->writeln(sprintf('Processing project "%s" (%d workspaces).', $projectId, count($rows))); + try { + $storageToken = $manageClient->createProjectStorageToken( + (int) $projectId, + [ + 'description' => 'Mass workspace deletion by workspace id', + 'expiresIn' => 1800, + // deleting a workspace is not allowed for a minimal token + 'canManageBuckets' => true, + // purging a configuration from trash needs an extra permission + 'canPurgeTrash' => $withConfiguration, + ] + ); + } catch (\Throwable $e) { + if ($e->getCode() === 403) { + $output->writeln(sprintf('Access denied to project "%s", skipping its %d workspaces.', $projectId, count($rows))); + $failed += count($rows); + continue; + } + throw $e; + } + assert(is_string($storageToken['token'])); + + $storageClient = new StorageApiClient([ + 'token' => $storageToken['token'], + 'url' => $connectionUrl, + ]); + + // index all workspaces of the project (all dev branches) by workspace id, + // plus live workspace ids per parent configuration (guard for purging trashed configs) + $workspacesById = []; + /** @var array> $liveWorkspaceIdsByConfig */ + $liveWorkspaceIdsByConfig = []; + $devBranches = new DevBranches($storageClient); + foreach ($devBranches->listBranches() as $branch) { + $branchId = $branch['id']; + assert(is_int($branchId)); + $branchName = $branch['name']; + assert(is_string($branchName)); + $branchClient = new BranchAwareClient($branchId, [ + 'token' => $storageToken['token'], + 'url' => $connectionUrl, + ]); + $workspacesClient = new Workspaces($branchClient); + foreach ($workspacesClient->listWorkspaces() as $workspace) { + $workspacesById[(string) $workspace['id']] = [ + 'branchId' => $branchId, + 'branchName' => $branchName, + 'workspace' => $workspace, + ]; + if (($workspace['component'] ?? '') !== '' && ($workspace['configurationId'] ?? '') !== '') { + $configKey = $branchId . '|' . $workspace['component'] . '/' . $workspace['configurationId']; + $liveWorkspaceIdsByConfig[$configKey][] = (string) $workspace['id']; + } + } + } + + // lazily fetched live/trashed configuration ids per branch + component + /** @var array, trashed: array}> $configStateCache */ + $configStateCache = []; + + foreach ($rows as $row) { + $workspaceId = $row['workspaceId']; + if (!isset($workspacesById[$workspaceId])) { + $notFound[] = sprintf('%s (project %s)', $workspaceId, $projectId); + continue; + } + $found = $workspacesById[$workspaceId]; + $workspace = $found['workspace']; + + $connection = $workspace['connection']; + $schema = $connection['schema'] ?? $workspace['name'] ?? ''; + $loginType = $connection['loginType'] ?? ''; + + $description = sprintf( + 'workspace "%s" (schema "%s", loginType "%s", configuration %s/%s, branch %s, created %s) in project "%s"', + $workspaceId, + $schema, + $loginType, + $workspace['component'] ?? '', + $workspace['configurationId'] ?? '', + $found['branchName'], + $workspace['created'], + $projectId, + ); + + if ($row['expectedSchema'] !== null && $row['expectedSchema'] !== $schema) { + $output->writeln(sprintf( + 'SKIP %s: schema does not match expected "%s".', + $description, + $row['expectedSchema'], + )); + $skippedSchemaMismatch++; + continue; + } + + if (!$anyLoginType && WorkspaceLoginType::tryFrom($loginType)?->isPasswordLogin() !== true) { + $output->writeln(sprintf( + 'SKIP %s: login type is not a password login. Use --%s to delete it anyway.', + $description, + self::OPTION_ANY_LOGIN_TYPE, + )); + $skippedLoginType++; + continue; + } + + $branchClient = new BranchAwareClient($found['branchId'], [ + 'token' => $storageToken['token'], + 'url' => $connectionUrl, + ]); + + if ($withConfiguration) { + $component = (string) ($workspace['component'] ?? ''); + $configurationId = (string) ($workspace['configurationId'] ?? ''); + if ($component === '' || $configurationId === '') { + $output->writeln(sprintf( + 'SKIP %s: workspace has no parent configuration, cannot delete with configuration.', + $description, + )); + $skippedConfigGuard++; + continue; + } + $components = new Components($branchClient); + + // the configuration may be live, sitting deleted in trash (purge pending, + // which is why its workspace and backend user still exist), or fully purged + $cacheKey = $found['branchId'] . '|' . $component; + if (!isset($configStateCache[$cacheKey])) { + $state = ['live' => [], 'trashed' => []]; + foreach (['live' => false, 'trashed' => true] as $stateKey => $isDeleted) { + $configurations = $components->listComponentConfigurations( + (new ListComponentConfigurationsOptions()) + ->setComponentId($component) + ->setIsDeleted($isDeleted) + ); + assert(is_array($configurations)); + foreach ($configurations as $configuration) { + assert(is_array($configuration)); + assert(is_scalar($configuration['id'])); + $state[$stateKey][(string) $configuration['id']] = true; + } + } + $configStateCache[$cacheKey] = $state; + } + $configState = $configStateCache[$cacheKey]; + + if (isset($configState['live'][$configurationId])) { + // live configuration: authoritative sibling check via the API + $configWorkspaces = $components->listConfigurationWorkspaces( + (new ListConfigurationWorkspacesOptions()) + ->setComponentId($component) + ->setConfigurationId($configurationId) + ); + assert(is_array($configWorkspaces)); + $configWorkspaceIds = array_map(static function ($configWorkspace): string { + assert(is_array($configWorkspace)); + assert(is_scalar($configWorkspace['id'])); + return (string) $configWorkspace['id']; + }, $configWorkspaces); + $deleteCallCount = 2; // trash + purge + $actionDescription = sprintf('delete configuration %s/%s (trash + purge)', $component, $configurationId); + } elseif (isset($configState['trashed'][$configurationId])) { + // trashed configuration: its workspaces cannot be listed anymore, + // check against live workspaces of the project referencing it instead + $configWorkspaceIds = $liveWorkspaceIdsByConfig[$found['branchId'] . '|' . $component . '/' . $configurationId] ?? []; + $deleteCallCount = 1; // already in trash, single delete purges it + $actionDescription = sprintf('purge trashed configuration %s/%s', $component, $configurationId); + } else { + $output->writeln(sprintf( + 'SKIP %s: configuration %s/%s is neither live nor in trash — orphaned workspace, delete it without --%s.', + $description, + $component, + $configurationId, + self::OPTION_WITH_CONFIGURATION, + )); + $skippedConfigGuard++; + continue; + } + + if ($configWorkspaceIds !== [$workspaceId]) { + $output->writeln(sprintf( + 'SKIP %s: configuration %s/%s does not own exactly this one workspace (has: %s).', + $description, + $component, + $configurationId, + implode(', ', $configWorkspaceIds), + )); + $skippedConfigGuard++; + continue; + } + + if (!$force) { + $output->writeln(sprintf('[DRY-RUN] Would %s including %s.', $actionDescription, $description)); + $deleted++; + continue; + } + + try { + // deleting a live configuration moves it to trash, deleting a trashed + // one purges it permanently (which drops the workspace and its backend user) + for ($i = 0; $i < $deleteCallCount; $i++) { + try { + $components->deleteConfiguration($component, $configurationId); + } catch (StorageClientException $e) { + if ($e->getStringCode() !== 'storage.components.cannotDeleteConfiguration') { + throw $e; + } + } + } + $output->writeln(sprintf('Done: %s including %s.', $actionDescription, $description)); + $deleted++; + } catch (\Throwable $e) { + $output->writeln(sprintf('Error: %s (%s): %s', $actionDescription, $description, $e->getMessage())); + $failed++; + } + continue; + } + + if (!$force) { + $output->writeln(sprintf('[DRY-RUN] Would delete %s.', $description)); + $deleted++; + continue; + } + + try { + $workspacesClient = new Workspaces($branchClient); + $workspacesClient->deleteWorkspace((int) $workspaceId); + $output->writeln(sprintf('Deleted %s.', $description)); + $deleted++; + } catch (\Throwable $e) { + $output->writeln(sprintf('Error deleting %s: %s', $description, $e->getMessage())); + $failed++; + } + } + + $tokensClient = new Tokens($storageClient); + assert(is_scalar($storageToken['id'])); + $tokensClient->dropToken((int) $storageToken['id']); + } + + $output->writeln(sprintf( + '%s %d of %d workspaces (%d skipped on login type, %d skipped on schema mismatch, %d skipped on configuration guard, %d failed, %d not found).', + $force ? 'Deleted' : '[DRY-RUN] Would delete', + $deleted, + $totalRows, + $skippedLoginType, + $skippedSchemaMismatch, + $skippedConfigGuard, + $failed, + count($notFound), + )); + if (count($notFound) !== 0) { + $output->writeln([ + 'Following workspaces were not found (already deleted or wrong project?):', + implode(', ', $notFound), + ]); + } + + return 0; + } +}