From f8efc1145596232ab28dadf52d7713d8927b5094 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Wed, 20 May 2026 12:36:55 +0200 Subject: [PATCH 1/4] Add manage:delete-sandbox-workspaces command Adds a new CLI command that deletes keboola.sandboxes workspaces in a project or organization. It pulls the workspace list from Connection, skips any workspace whose schema still has an active session in the Editor Service, then deletes the workspace configuration followed by the workspace itself for the remaining ones. Scoped by --project-id or --organization-id (mutually exclusive), filtered by a creation-date window, with --force gating real deletions and verbose per-workspace logging in dry-run as well. Co-Authored-By: Claude Opus 4.7 (1M context) --- cli.php | 2 + .../Command/DeleteSandboxWorkspaces.php | 488 ++++++++++++++++++ 2 files changed, 490 insertions(+) create mode 100644 src/Keboola/Console/Command/DeleteSandboxWorkspaces.php diff --git a/cli.php b/cli.php index 0889535..52fc3eb 100644 --- a/cli.php +++ b/cli.php @@ -10,6 +10,7 @@ use Keboola\Console\Command\DeleteOrganizationOwnerlessWorkspaces; use Keboola\Console\Command\DeleteOrphanedWorkspaces; use Keboola\Console\Command\DeleteOwnerlessWorkspaces; +use Keboola\Console\Command\DeleteSandboxWorkspaces; use Keboola\Console\Command\DescribeOrganizationWorkspaces; use Keboola\Console\Command\LineageEventsExport; use Keboola\Console\Command\MassDeleteProjectWorkspaces; @@ -48,6 +49,7 @@ $application->add(new OrganizationStorageBackend()); $application->add(new DeleteOwnerlessWorkspaces()); $application->add(new DeleteOrganizationOwnerlessWorkspaces()); +$application->add(new DeleteSandboxWorkspaces()); $application->add(new RemoveUserFromOrganizationProjects()); $application->add(new ReactivateSchedules()); $application->add(new DescribeOrganizationWorkspaces()); diff --git a/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php b/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php new file mode 100644 index 0000000..c148dd0 --- /dev/null +++ b/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php @@ -0,0 +1,488 @@ +setName('manage:delete-sandbox-workspaces') + ->setDescription(sprintf( + 'Delete %s workspaces in a project or organization that have no active editor session, ' + . 'filtered by workspace creation date.', + self::COMPONENT_ID, + )) + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Use [--force, -f] to do it for real.') + ->addOption( + 'project-id', + 'p', + InputOption::VALUE_REQUIRED, + 'Single project ID to clean. Mutually exclusive with --organization-id.', + ) + ->addOption( + 'organization-id', + 'o', + InputOption::VALUE_REQUIRED, + 'Organization ID — iterates all projects in the organization. ' + . 'Mutually exclusive with --project-id.', + ) + ->addOption( + 'created-after', + null, + InputOption::VALUE_REQUIRED, + 'Only consider workspaces created at or after this date ' + . '(strtotime expression, e.g. "-30 days", "2026-01-01"). Default: "-30 days".', + '-30 days', + ) + ->addOption( + 'created-before', + null, + InputOption::VALUE_REQUIRED, + 'Only consider workspaces created before this date ' + . '(strtotime expression, e.g. "-1 day", "now"). Default: "now".', + 'now', + ) + ->addArgument( + 'manageToken', + InputArgument::REQUIRED, + 'Keboola Manage API token to use', + ) + ->addArgument( + 'hostnameSuffix', + InputArgument::OPTIONAL, + 'Keboola Connection Hostname Suffix', + 'keboola.com', + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $manageToken = $input->getArgument('manageToken'); + assert(is_string($manageToken)); + assert($manageToken !== ''); + + $hostnameSuffix = $input->getArgument('hostnameSuffix'); + assert(is_string($hostnameSuffix)); + assert($hostnameSuffix !== ''); + + $projectIdOpt = $input->getOption('project-id'); + $organizationIdOpt = $input->getOption('organization-id'); + assert($projectIdOpt === null || is_string($projectIdOpt)); + assert($organizationIdOpt === null || is_string($organizationIdOpt)); + + if ($projectIdOpt !== null && $organizationIdOpt !== null) { + throw new InvalidArgumentException( + 'Cannot use both --project-id and --organization-id options at the same time.', + ); + } + if ($projectIdOpt === null && $organizationIdOpt === null) { + throw new InvalidArgumentException( + 'Either --project-id or --organization-id option must be provided.', + ); + } + + $createdAfterStr = $input->getOption('created-after'); + $createdBeforeStr = $input->getOption('created-before'); + assert(is_string($createdAfterStr)); + assert(is_string($createdBeforeStr)); + + $createdAfter = strtotime($createdAfterStr); + if ($createdAfter === false) { + throw new InvalidArgumentException(sprintf('Invalid --created-after value: %s', $createdAfterStr)); + } + $createdBefore = strtotime($createdBeforeStr); + if ($createdBefore === false) { + throw new InvalidArgumentException(sprintf('Invalid --created-before value: %s', $createdBeforeStr)); + } + if ($createdBefore <= $createdAfter) { + throw new InvalidArgumentException(sprintf( + '--created-before (%s) must be later than --created-after (%s).', + date('Y-m-d H:i:s', $createdBefore), + date('Y-m-d H:i:s', $createdAfter), + )); + } + + $force = (bool) $input->getOption('force'); + + $serviceClient = new ServiceClient($hostnameSuffix); + $connectionUrl = $serviceClient->getConnectionServiceUrl(); + $editorUrl = $serviceClient->getEditorServiceUrl(); + + $manageClient = new Client(['token' => $manageToken, 'url' => $connectionUrl]); + + // Resolve target projects + if ($organizationIdOpt !== null) { + if (!ctype_digit($organizationIdOpt)) { + throw new InvalidArgumentException('--organization-id must be a numeric string.'); + } + $organizationId = (int) $organizationIdOpt; + try { + $organization = $manageClient->getOrganization($organizationId); + } catch (ClientException $e) { + throw new \RuntimeException(sprintf( + 'Failed to load organization %d: %s', + $organizationId, + $e->getMessage(), + ), 0, $e); + } + $projects = $organization['projects']; + $targetDesc = sprintf( + 'organization %d ("%s") — %d project(s)', + $organizationId, + $organization['name'] ?? '?', + count($projects), + ); + } else { + if (!ctype_digit($projectIdOpt)) { + throw new InvalidArgumentException('--project-id must be a numeric string.'); + } + try { + $project = $manageClient->getProject($projectIdOpt); + } catch (ClientException $e) { + throw new \RuntimeException(sprintf( + 'Failed to load project %s: %s', + $projectIdOpt, + $e->getMessage(), + ), 0, $e); + } + $projects = [$project]; + $targetDesc = sprintf('project %s ("%s")', $project['id'], $project['name'] ?? '?'); + } + + $this->writeBlock($output, 'Configuration', [ + sprintf('Target: %s', $targetDesc), + sprintf('Component: %s', self::COMPONENT_ID), + sprintf( + 'Created window: from %s to %s', + date('Y-m-d H:i:s', $createdAfter), + date('Y-m-d H:i:s', $createdBefore), + ), + sprintf('Connection URL: %s', $connectionUrl), + sprintf('Editor URL: %s', $editorUrl), + sprintf('Mode: %s', $force ? 'FORCE (deletions will happen)' : 'DRY-RUN (no changes)'), + ]); + + $totalProjectsProcessed = 0; + $totalProjectsSkipped = 0; + $totalWorkspaces = 0; + $totalCandidates = 0; + $totalSkippedSession = 0; + $totalSkippedComponent = 0; + $totalSkippedDate = 0; + $totalDeleted = 0; + $totalDeleteErrors = 0; + + /** @var array> $summary */ + $summary = []; + + foreach ($projects as $project) { + $projectKey = sprintf('%s (%s)', $project['name'] ?? '?', $project['id']); + $this->writeBlock($output, sprintf('Project %s : %s', $project['id'], $project['name'] ?? '?')); + + try { + $storageToken = $manageClient->createProjectStorageToken( + $project['id'], + [ + 'description' => 'Maintenance Sandbox Workspace Cleaner', + 'expiresIn' => 1800, + 'canManageTokens' => true, + ], + ); + } catch (\Throwable $e) { + if ($e->getCode() === 403) { + $output->writeln(sprintf(' WARN: Access denied to project %s, skipping.', $project['id'])); + $output->writeln(''); + $totalProjectsSkipped++; + continue; + } + throw $e; + } + + $storageClient = new StorageApiClient([ + 'token' => $storageToken['token'], + 'url' => $connectionUrl, + 'backoffMaxTries' => 1, + 'logger' => new ConsoleLogger($output), + ]); + $editorClient = new EditorServiceClient($editorUrl, $storageToken['token']); + + // Index editor sessions by workspaceSchema for O(1) lookup against workspace credentials. + $sessionsBySchema = []; + foreach ($editorClient->listSessions() as $session) { + $sessionsBySchema[$session['workspaceSchema']] = $session; + } + $output->writeln(sprintf( + ' Editor sessions in project: %d (across all branches)', + count($sessionsBySchema), + )); + + $devBranches = new DevBranches($storageClient); + $branchesList = $devBranches->listBranches(); + + $projectWorkspaces = 0; + $projectCandidates = 0; + $projectSkippedSession = 0; + $projectSkippedComponent = 0; + $projectSkippedDate = 0; + $projectDeleted = 0; + $projectDeleteErrors = 0; + + $summary[$projectKey] = []; + + foreach ($branchesList as $branch) { + $branchId = $branch['id']; + $branchStorageClient = new BranchAwareClient($branchId, [ + 'token' => $storageToken['token'], + 'url' => $connectionUrl, + 'backoffMaxTries' => 1, + ]); + $workspacesClient = new Workspaces($branchStorageClient); + $workspaceList = $workspacesClient->listWorkspaces(); + + $output->writeln(sprintf( + ' Branch "%s" (#%s): %d workspace(s)', + $branch['name'], + $branchId, + count($workspaceList), + )); + $projectWorkspaces += count($workspaceList); + + foreach ($workspaceList as $workspace) { + $workspaceComponent = (string) ($workspace['component'] ?? ''); + $workspaceId = $workspace['id']; + $createdStr = $workspace['created']; + $createdTs = strtotime($createdStr); + $schema = (string) ($workspace['connection']['schema'] ?? ''); + $configurationId = (string) ($workspace['configurationId'] ?? ''); + + if ($workspaceComponent !== self::COMPONENT_ID) { + $output->writeln(sprintf( + ' - SKIP workspace %s (component "%s" != "%s")', + (string) $workspaceId, + $workspaceComponent, + self::COMPONENT_ID, + )); + $projectSkippedComponent++; + continue; + } + + if ($createdTs === false || $createdTs < $createdAfter || $createdTs >= $createdBefore) { + $output->writeln(sprintf( + ' - SKIP workspace %s (created %s outside window)', + (string) $workspaceId, + $createdStr, + )); + $projectSkippedDate++; + continue; + } + + if ($configurationId === '') { + $output->writeln(sprintf( + ' - SKIP workspace %s (created %s) — no configurationId, cannot resolve config', + (string) $workspaceId, + $createdStr, + )); + $projectSkippedComponent++; + continue; + } + + if ($schema !== '' && isset($sessionsBySchema[$schema])) { + $session = $sessionsBySchema[$schema]; + $output->writeln(sprintf( + ' - SKIP workspace %s (created %s, schema %s) — active editor session %s', + (string) $workspaceId, + $createdStr, + $schema, + $session['id'], + )); + $projectSkippedSession++; + continue; + } + + $projectCandidates++; + $output->writeln(sprintf( + ' - DELETE workspace %s (created %s, schema "%s", config %s/%s, branch %s)', + (string) $workspaceId, + $createdStr, + $schema, + self::COMPONENT_ID, + $configurationId, + (string) $branchId, + )); + + $summary[$projectKey][] = [ + 'workspaceId' => $workspaceId, + 'configurationId' => $configurationId, + 'branchId' => $branchId, + 'schema' => $schema, + 'created' => $createdStr, + ]; + + if (!$force) { + continue; + } + + // 1) Delete configuration (trash then purge) — matches existing cleanup commands. + $configDeleted = false; + $components = new Components($branchStorageClient); + try { + $components->deleteConfiguration(self::COMPONENT_ID, $configurationId); + $components->deleteConfiguration(self::COMPONENT_ID, $configurationId); + $configDeleted = true; + $output->writeln(sprintf( + ' Deleted configuration %s/%s', + self::COMPONENT_ID, + $configurationId, + )); + } catch (StorageClientException $e) { + if ($e->getStringCode() === 'storage.components.notFound') { + $output->writeln(sprintf( + ' Configuration %s/%s already gone', + self::COMPONENT_ID, + $configurationId, + )); + $configDeleted = true; + } else { + $output->writeln(sprintf( + ' ERROR deleting configuration %s/%s: %s', + self::COMPONENT_ID, + $configurationId, + $e->getMessage(), + )); + } + } + + // 2) Delete the workspace itself. + try { + $workspacesClient->deleteWorkspace($workspaceId); + $output->writeln(sprintf(' Deleted workspace %s', (string) $workspaceId)); + if ($configDeleted) { + $projectDeleted++; + } else { + $projectDeleteErrors++; + } + } catch (\Throwable $e) { + $output->writeln(sprintf( + ' ERROR deleting workspace %s: %s', + (string) $workspaceId, + $e->getMessage(), + )); + $projectDeleteErrors++; + } + } + } + + $output->writeln(''); + $output->writeln(sprintf( + ' Project summary: %d workspace(s) seen, %d candidate(s), %d deleted, %d error(s); ' + . 'skipped: %d session, %d component, %d date', + $projectWorkspaces, + $projectCandidates, + $projectDeleted, + $projectDeleteErrors, + $projectSkippedSession, + $projectSkippedComponent, + $projectSkippedDate, + )); + $output->writeln(''); + + try { + $tokensClient = new Tokens($storageClient); + $tokensClient->dropToken($storageToken['id']); + } catch (\Throwable $e) { + $output->writeln(sprintf( + ' WARN: Could not drop temporary token %s: %s', + $storageToken['id'], + $e->getMessage(), + )); + } + + $totalProjectsProcessed++; + $totalWorkspaces += $projectWorkspaces; + $totalCandidates += $projectCandidates; + $totalSkippedSession += $projectSkippedSession; + $totalSkippedComponent += $projectSkippedComponent; + $totalSkippedDate += $projectSkippedDate; + $totalDeleted += $projectDeleted; + $totalDeleteErrors += $projectDeleteErrors; + } + + // Per-project candidate listing + $output->writeln(''); + $output->writeln('=== Per-project candidates ==='); + foreach ($summary as $projectKey => $rows) { + if (count($rows) === 0) { + continue; + } + $output->writeln(sprintf(' Project: %s', $projectKey)); + foreach ($rows as $row) { + $output->writeln(sprintf( + ' - workspaceId=%s configurationId=%s branchId=%s schema=%s created=%s', + (string) $row['workspaceId'], + $row['configurationId'], + (string) $row['branchId'], + $row['schema'] !== '' ? $row['schema'] : '(none)', + $row['created'], + )); + } + } + + $summaryLines = [ + sprintf('Projects processed: %d', $totalProjectsProcessed), + sprintf('Projects skipped: %d', $totalProjectsSkipped), + sprintf('Workspaces seen: %d', $totalWorkspaces), + sprintf('Candidates (to delete): %d', $totalCandidates), + sprintf('Skipped (active session): %d', $totalSkippedSession), + sprintf('Skipped (other component): %d', $totalSkippedComponent), + sprintf('Skipped (out of window): %d', $totalSkippedDate), + ]; + if ($force) { + $summaryLines[] = sprintf('Deleted: %d', $totalDeleted); + $summaryLines[] = sprintf('Delete errors: %d', $totalDeleteErrors); + } else { + $summaryLines[] = 'Mode: DRY-RUN (re-run with --force to delete)'; + } + $this->writeBlock($output, 'Final summary', $summaryLines); + + return 0; + } + + /** + * @param list $lines + */ + private function writeBlock(OutputInterface $output, string $title, array $lines = []): void + { + $width = max(70, strlen($title) + 4); + $separator = str_repeat('=', $width); + $output->writeln($separator); + $output->writeln(' ' . $title); + $output->writeln($separator); + foreach ($lines as $line) { + $output->writeln(' ' . $line); + } + if ($lines !== []) { + $output->writeln(''); + } + } +} From 145abec83c1eee639edd3dc7af6f7cb5e89de691 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Wed, 20 May 2026 15:26:03 +0200 Subject: [PATCH 2/4] some fixes --- src/Keboola/Console/Command/DeleteSandboxWorkspaces.php | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php b/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php index c148dd0..c555d43 100644 --- a/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php +++ b/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php @@ -22,7 +22,7 @@ class DeleteSandboxWorkspaces extends Command { - private const COMPONENT_ID = 'keboola.sandboxes'; + private const string COMPONENT_ID = 'keboola.sandboxes'; protected function configure(): void { @@ -206,7 +206,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int [ 'description' => 'Maintenance Sandbox Workspace Cleaner', 'expiresIn' => 1800, - 'canManageTokens' => true, + 'canManageBuckets' => true, + 'canPurgeTrash' => true, ], ); } catch (\Throwable $e) { @@ -347,7 +348,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int $configDeleted = false; $components = new Components($branchStorageClient); try { + // delete $components->deleteConfiguration(self::COMPONENT_ID, $configurationId); + // purge (from trash) $components->deleteConfiguration(self::COMPONENT_ID, $configurationId); $configDeleted = true; $output->writeln(sprintf( @@ -356,7 +359,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $configurationId, )); } catch (StorageClientException $e) { - if ($e->getStringCode() === 'storage.components.notFound') { + if (str_contains($e->getMessage(), 'not found')) { $output->writeln(sprintf( ' Configuration %s/%s already gone', self::COMPONENT_ID, From bbabe1dccc59a648830094d2318d6d3761017c8f Mon Sep 17 00:00:00 2001 From: Odin Date: Fri, 22 May 2026 16:02:50 +0200 Subject: [PATCH 3/4] Address PR #92 feedback on delete-sandbox-workspaces - Make --component configurable (default keboola.sandboxes); empty value permitted so workspaces with no component can be targeted. - Enrich SKIP/DELETE log lines and the per-project candidates listing with component, configurationId, loginType and owner email (from creatorToken.description), falling back to "(none)" when absent. - Override the active-editor-session SKIP when the workspace points at a configurationId that no longer exists: GET the config, treat 404 as a stale session, log a NOTICE and proceed to delete the workspace. --- .../Command/DeleteSandboxWorkspaces.php | 126 ++++++++++++++---- 1 file changed, 99 insertions(+), 27 deletions(-) diff --git a/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php b/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php index c555d43..06f7b70 100644 --- a/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php +++ b/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php @@ -22,18 +22,25 @@ class DeleteSandboxWorkspaces extends Command { - private const string COMPONENT_ID = 'keboola.sandboxes'; + private const string DEFAULT_COMPONENT_ID = 'keboola.sandboxes'; protected function configure(): void { $this ->setName('manage:delete-sandbox-workspaces') - ->setDescription(sprintf( - 'Delete %s workspaces in a project or organization that have no active editor session, ' - . 'filtered by workspace creation date.', - self::COMPONENT_ID, - )) + ->setDescription( + 'Delete workspaces of a given component in a project or organization that have no active ' + . 'editor session, filtered by workspace creation date.', + ) ->addOption('force', 'f', InputOption::VALUE_NONE, 'Use [--force, -f] to do it for real.') + ->addOption( + 'component', + 'c', + InputOption::VALUE_REQUIRED, + 'Component ID whose workspaces should be cleaned (e.g. "keboola.sandboxes", ' + . '"keboola.snowflake-transformation").', + self::DEFAULT_COMPONENT_ID, + ) ->addOption( 'project-id', 'p', @@ -91,6 +98,9 @@ protected function execute(InputInterface $input, OutputInterface $output): int assert($projectIdOpt === null || is_string($projectIdOpt)); assert($organizationIdOpt === null || is_string($organizationIdOpt)); + $componentId = $input->getOption('component'); + assert(is_string($componentId)); + if ($projectIdOpt !== null && $organizationIdOpt !== null) { throw new InvalidArgumentException( 'Cannot use both --project-id and --organization-id options at the same time.', @@ -172,7 +182,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $this->writeBlock($output, 'Configuration', [ sprintf('Target: %s', $targetDesc), - sprintf('Component: %s', self::COMPONENT_ID), + sprintf('Component: %s', $componentId !== '' ? $componentId : '(none)'), sprintf( 'Created window: from %s to %s', date('Y-m-d H:i:s', $createdAfter), @@ -193,7 +203,7 @@ protected function execute(InputInterface $input, OutputInterface $output): int $totalDeleted = 0; $totalDeleteErrors = 0; - /** @var array> $summary */ + /** @var array> $summary */ $summary = []; foreach ($projects as $project) { @@ -276,13 +286,24 @@ protected function execute(InputInterface $input, OutputInterface $output): int $createdTs = strtotime($createdStr); $schema = (string) ($workspace['connection']['schema'] ?? ''); $configurationId = (string) ($workspace['configurationId'] ?? ''); + $loginType = (string) ($workspace['connection']['loginType'] ?? ''); + $ownerEmail = (string) ($workspace['creatorToken']['description'] ?? ''); + + $loginTypeForLog = $loginType !== '' ? $loginType : '(none)'; + $ownerEmailForLog = $ownerEmail !== '' ? $ownerEmail : '(none)'; + $configurationIdForLog = $configurationId !== '' ? $configurationId : '(none)'; + $componentForLog = $workspaceComponent !== '' ? $workspaceComponent : '(none)'; - if ($workspaceComponent !== self::COMPONENT_ID) { + if ($workspaceComponent !== $componentId) { $output->writeln(sprintf( - ' - SKIP workspace %s (component "%s" != "%s")', + ' - SKIP workspace %s (component "%s" != "%s", config %s, ' + . 'login %s, owner %s)', (string) $workspaceId, - $workspaceComponent, - self::COMPONENT_ID, + $componentForLog, + $componentId, + $configurationIdForLog, + $loginTypeForLog, + $ownerEmailForLog, )); $projectSkippedComponent++; continue; @@ -290,9 +311,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($createdTs === false || $createdTs < $createdAfter || $createdTs >= $createdBefore) { $output->writeln(sprintf( - ' - SKIP workspace %s (created %s outside window)', + ' - SKIP workspace %s (created %s outside window, component %s, ' + . 'config %s, login %s, owner %s)', (string) $workspaceId, $createdStr, + $componentForLog, + $configurationIdForLog, + $loginTypeForLog, + $ownerEmailForLog, )); $projectSkippedDate++; continue; @@ -300,9 +326,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($configurationId === '') { $output->writeln(sprintf( - ' - SKIP workspace %s (created %s) — no configurationId, cannot resolve config', + ' - SKIP workspace %s (created %s, component %s, config %s, ' + . 'login %s, owner %s) — no configurationId, cannot resolve config', (string) $workspaceId, $createdStr, + $componentForLog, + $configurationIdForLog, + $loginTypeForLog, + $ownerEmailForLog, )); $projectSkippedComponent++; continue; @@ -310,26 +341,62 @@ protected function execute(InputInterface $input, OutputInterface $output): int if ($schema !== '' && isset($sessionsBySchema[$schema])) { $session = $sessionsBySchema[$schema]; + + // If the workspace points at a configuration that no longer exists, the editor + // session is stale — delete the workspace anyway. + $configMissing = false; + if ($configurationId !== '') { + try { + (new Components($branchStorageClient)) + ->getConfiguration($componentId, $configurationId); + } catch (StorageClientException $e) { + if ($e->getCode() === 404) { + $configMissing = true; + } else { + throw $e; + } + } + } + + if (!$configMissing) { + $output->writeln(sprintf( + ' - SKIP workspace %s (created %s, schema %s, component %s, ' + . 'config %s, login %s, owner %s) — active editor session %s', + (string) $workspaceId, + $createdStr, + $schema, + $componentForLog, + $configurationIdForLog, + $loginTypeForLog, + $ownerEmailForLog, + $session['id'], + )); + $projectSkippedSession++; + continue; + } + $output->writeln(sprintf( - ' - SKIP workspace %s (created %s, schema %s) — active editor session %s', + ' - NOTICE workspace %s has active editor session %s but ' + . 'configuration %s/%s no longer exists — proceeding to delete', (string) $workspaceId, - $createdStr, - $schema, $session['id'], + $componentId, + $configurationId, )); - $projectSkippedSession++; - continue; } $projectCandidates++; $output->writeln(sprintf( - ' - DELETE workspace %s (created %s, schema "%s", config %s/%s, branch %s)', + ' - DELETE workspace %s (created %s, schema "%s", config %s/%s, branch %s, ' + . 'login %s, owner %s)', (string) $workspaceId, $createdStr, $schema, - self::COMPONENT_ID, + $componentId, $configurationId, (string) $branchId, + $loginTypeForLog, + $ownerEmailForLog, )); $summary[$projectKey][] = [ @@ -338,6 +405,8 @@ protected function execute(InputInterface $input, OutputInterface $output): int 'branchId' => $branchId, 'schema' => $schema, 'created' => $createdStr, + 'loginType' => $loginType, + 'ownerEmail' => $ownerEmail, ]; if (!$force) { @@ -349,27 +418,27 @@ protected function execute(InputInterface $input, OutputInterface $output): int $components = new Components($branchStorageClient); try { // delete - $components->deleteConfiguration(self::COMPONENT_ID, $configurationId); + $components->deleteConfiguration($componentId, $configurationId); // purge (from trash) - $components->deleteConfiguration(self::COMPONENT_ID, $configurationId); + $components->deleteConfiguration($componentId, $configurationId); $configDeleted = true; $output->writeln(sprintf( ' Deleted configuration %s/%s', - self::COMPONENT_ID, + $componentId, $configurationId, )); } catch (StorageClientException $e) { if (str_contains($e->getMessage(), 'not found')) { $output->writeln(sprintf( ' Configuration %s/%s already gone', - self::COMPONENT_ID, + $componentId, $configurationId, )); $configDeleted = true; } else { $output->writeln(sprintf( ' ERROR deleting configuration %s/%s: %s', - self::COMPONENT_ID, + $componentId, $configurationId, $e->getMessage(), )); @@ -441,12 +510,15 @@ protected function execute(InputInterface $input, OutputInterface $output): int $output->writeln(sprintf(' Project: %s', $projectKey)); foreach ($rows as $row) { $output->writeln(sprintf( - ' - workspaceId=%s configurationId=%s branchId=%s schema=%s created=%s', + ' - workspaceId=%s configurationId=%s branchId=%s schema=%s created=%s ' + . 'loginType=%s owner=%s', (string) $row['workspaceId'], $row['configurationId'], (string) $row['branchId'], $row['schema'] !== '' ? $row['schema'] : '(none)', $row['created'], + $row['loginType'] !== '' ? $row['loginType'] : '(none)', + $row['ownerEmail'] !== '' ? $row['ownerEmail'] : '(none)', )); } } From 67c20461f416671637b49eef74df2bd2295b4762 Mon Sep 17 00:00:00 2001 From: Odin Date: Fri, 22 May 2026 16:36:04 +0200 Subject: [PATCH 4/4] Fix phpstan: drop redundant configurationId !== '' guard The earlier SKIP for empty configurationId already returns, so the nested check inside the active-session branch was unreachable as false. --- .../Command/DeleteSandboxWorkspaces.php | 18 ++++++++---------- 1 file changed, 8 insertions(+), 10 deletions(-) diff --git a/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php b/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php index 06f7b70..dceeddd 100644 --- a/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php +++ b/src/Keboola/Console/Command/DeleteSandboxWorkspaces.php @@ -345,16 +345,14 @@ protected function execute(InputInterface $input, OutputInterface $output): int // If the workspace points at a configuration that no longer exists, the editor // session is stale — delete the workspace anyway. $configMissing = false; - if ($configurationId !== '') { - try { - (new Components($branchStorageClient)) - ->getConfiguration($componentId, $configurationId); - } catch (StorageClientException $e) { - if ($e->getCode() === 404) { - $configMissing = true; - } else { - throw $e; - } + try { + (new Components($branchStorageClient)) + ->getConfiguration($componentId, $configurationId); + } catch (StorageClientException $e) { + if ($e->getCode() === 404) { + $configMissing = true; + } else { + throw $e; } }