diff --git a/README.md b/README.md index 006ae71..f95a596 100644 --- a/README.md +++ b/README.md @@ -103,120 +103,318 @@ You can add a project feature to all the project templates available on the stac ## Workspaces and sandboxes -### Delete Orphaned Workspaces command -This command can be used to delete all the workspaces in a project that were made for componentIds in the `component-list` argument -and that were created before the `until-date` argument. -The usecase for this command is to remove workspaces not cleaned after transformation failures. -It will perform a dry run unleass the `--force/-f` option is applied. +### Read this before deleting anything + +A component-created workspace has three separate things attached to it: + +1. the **workspace record** in Storage metadata, +2. the **backend user / schema** (e.g. the Snowflake user and its schema), and +3. the **parent component configuration** that created it. + +Deleting the workspace (`deleteWorkspace`) removes 1 and 2 and leaves the configuration alone. +Deleting the **configuration** (delete twice = move to trash, then purge) cascades and takes its +workspaces with it. Which of the two a command does is the single most important thing about it: + +- For sandbox-type components (`keboola.sandboxes`) the configuration **is** the sandbox, so + deleting it is the correct cleanup. +- For transformation components (`keboola.snowflake-transformation` and friends) the configuration + is **the user's transformation code**. Deleting it destroys user work, not a leaked resource. + +Every command in this section is dry-run by default and needs `--force`/`-f` to change anything. +Always read the dry-run output first, and check the "Destroys" line below before you pass `-f`. + +There is also a fourth state worth naming, because it needs its own tool: the workspace record can +already be gone from Storage metadata while the **backend user survives** (a failed or half-finished +delete). Nothing that works through workspaces or editor sessions can see those - see +`--ignore-backend-errors` on `storage:delete-orphaned-workspaces`. + +### Which command should I use? + +| What you have / want to do | Command | +| --- | --- | +| Just **see what is there** in an organization, as a CSV report | `manage:describe-organization-workspaces` | +| A list of workspaces and you want **what state each one is in** | `manage:check-project-workspaces-state` | +| An **explicit list of workspace IDs** to delete, with safety guards | `manage:delete-project-workspaces-by-id` | +| Leaked workspaces of **one component, older than a cutoff**, in one project | `storage:delete-orphaned-workspaces` | +| The same, across **whole organizations** | `manage:delete-organization-workspaces` | +| Workspaces whose **owner is no longer in the project**, in one project | `storage:delete-ownerless-workspaces` | +| The same, across a **whole organization** | `manage:delete-organization-ownerless-workspaces` | +| An explicit CSV list of `projectId,WORKSPACE_schema` (legacy) | `manage:mass-delete-project-workspaces` | +| Workspace already gone from metadata but the **backend user survives** | `storage:delete-orphaned-workspaces --ignore-backend-errors` | + +Rules of thumb: + +- **Start with a read-only command.** Use `manage:describe-organization-workspaces` when you want to + survey a whole organization, and `manage:check-project-workspaces-state` when you already have a + list of workspaces and need to know what is still live. Both report the component, creator and + state you need in order to pick the right command and the right filter below. +- **Prefer `manage:delete-project-workspaces-by-id` when you have a concrete list.** It is the only + deletion command with layered guards (login type, expected schema, and a check that a configuration + owns exactly the one workspace you named), and it deletes the workspace without touching the + configuration unless you explicitly ask. `manage:mass-delete-project-workspaces` predates it, needs + an interactively pasted token per project, and always purges the configuration - prefer the by-id + command unless you specifically need schema-based matching. +- The two `*-orphaned-*` commands and the two `*-ownerless-*` commands are each **the same selection + logic at two different scopes** (single project vs. organization). Pick by scope; the behaviour is + otherwise the same. +- "Orphaned" is a misnomer inherited from the command name: those commands do **not** detect whether + anything is actually orphaned. They select purely by *component + age*. You are responsible for + choosing a component where that is a safe proxy. +- `manage:mass-delete-project-workspaces` resolves your schemas through **editor sessions**, so it + cannot find a workspace that has no session. Its "not found" list is the important part of its + output, not an afterthought. -- Create a Storage token +### Describe Connection Workspaces for an organization +Read-only. Writes a CSV describing all Connection workspaces in an organization, across all dev +branches of every project. Use it to decide what to delete and with which command. -- Run the command - ``` - php ./cli.php storage:delete-orphaned-workspaces [--force/-f] - ``` +``` +php ./cli.php manage:describe-organization-workspaces [] +``` +Arguments: +- manage-token (required) Manage API token. +- organization-id (required) Target organization ID. +- output-file (required) Path of the CSV to write. +- hostname-suffix (optional, default: keboola.com) Connection host suffix (e.g. eu-central-1.keboola.com). -### Delete Orphaned Workspaces in Organization command -This command can be used to delete all workspace in an organization that were made for componentIds in the `component` argument -and that were created before the `until-date` argument. +Destroys: nothing, this command is read-only. -The usecase for this command is to remove workspaces that were not cleaned after transformation failures. -It will perform a dry run unleass the `--force/-f` option is applied. +The output CSV has the header: +``` +projectId,projectName,branchId,branchName,componentId,configurationId,creatorEmail,activeUser,createdDate,snowflakeSchema,readOnlyStorageAccess +``` +`activeUser` is `true` when the workspace's creator email still matches a current user of the project, +which is the same signal the `*-ownerless-*` commands act on. -- Create a Storage token +### Check the state of a list of workspaces +Read-only. Takes a list of workspaces you already care about (typically left over from an earlier +cleanup) and reports, per row, whether the workspace is still live, whether its configuration is +live / in the trash / gone, and when its configuration last ran a job. Each row gets a suggested +follow-up command, so this is the triage step before any of the deletion commands. -- Run the command - ``` - php ./cli.php manage:delete-organization-workspaces [--force/-f] - ``` +``` +php ./cli.php manage:check-project-workspaces-state [] +``` +Arguments: +- manage-token (required) Manage API token (super admin); used to mint a short-lived Storage token per project. +- source-file (required) CSV **without header**, either two or four columns per line: `projectId,workspaceSchema` or `projectId,workspaceSchema,componentId,configurationId`. Schemas must start with `WORKSPACE_`. +- output-file (required) Path of the CSV report to write. +- hostname-suffix (optional, default: keboola.com) Connection host suffix. -### Describe Connection Workspaces for an organization -This command takes an output file argument and writes out a csv describing all connection workspaces in an organisation. -The output file has header: -``` -'projectId', -'projectName', -'branchId', -'branchName', -'componentId', -'configurationId', -'creatorEmail', -'createdDate', -'snowflakeSchema', -'readOnlyStorageAccess' +Destroys: nothing, this command is read-only. + +Each row is classified as one of: + +| status | meaning | suggested follow-up | +| --- | --- | --- | +| `live` | the workspace still exists | `manage:delete-project-workspaces-by-id` | +| `config_in_trash` | configuration sits in the trash, so its workspace and backend user still exist | purge the configuration from the trash | +| `config_live_workspace_gone` | configuration is live but this workspace is not - backend user likely orphaned | investigate | +| `purged_or_orphan` | neither workspace nor configuration found; a backend user may survive | drop the backend user (`storage:workspace:drop-failed-workspaces-from-metadata`) | +| `not_live_no_config_ref` | not live and no component/configuration reference to check against | investigate | +| `access_denied` | the manage token cannot reach the project | grant access and re-run | + +The output CSV header is: +``` +projectId,workspaceSchema,componentId,configurationId,status,suggestedAction,workspaceId,branchId,branchName,loginType,liveComponentId,liveConfigurationId,configState,configName,configCreated,configCreator,lastJobStatus,lastJobCreated,lastJobEnd,note +``` + +Behavior: +- Groups the input by project and mints one short-lived Storage token per project. +- Indexes live workspaces by schema across **all** dev branches, then lists live and trashed + configurations of every referenced component, **per branch**. Branches hold independent copies of a + configuration under the same id, so the states are deliberately not merged across branches - a + configuration live in one branch and trashed in another is reported in the workspace's own branch, + and the other branches go into the `note` column. +- Looks up the most recent job of each configuration through the Queue API and reports it in the + `lastJob*` columns, which is usually the deciding signal for whether a configuration is still in + use. It first runs a sanity query for *any* job in the project and warns when that comes back + empty, so that blank `lastJob*` columns are not misread as "never used". +- Prints a per-status summary at the end. + +### Delete specific workspaces by ID +Deletes individual workspaces named explicitly by ID, across multiple projects. This is the +list-driven deletion command to reach for: it does not guess, and it refuses anything that does not +match what you described. + +``` +php ./cli.php manage:delete-project-workspaces-by-id [-f|--force] [--any-login-type] [--with-configuration] [] ``` Arguments: -- Manage Token *required* -- Organisation Id *required* -- Output File *required* -- Hostname suffix *optional* (default: keboola.com) +- manage-token (required) Manage API token (super admin); used to mint a short-lived Storage token per project. +- source-file (required) CSV **without header**, two or three columns per line: `projectId,workspaceId[,expectedSchema]`. When `expectedSchema` is present the workspace's actual schema must match it or the row is skipped. +- hostname-suffix (optional, default: keboola.com) Connection host suffix. -- Run the command - ``` - php ./cli.php manage:describe-organization-workspaces - ``` +Options: +- `--force` / `-f` Actually delete. Without it only reports. +- `--any-login-type` Also delete workspaces that do not use password login (key-pair etc.). **Use with care** - by default only password-login (`LEGACY_SERVICE`) workspaces are touched. +- `--with-configuration` Delete the whole parent configuration (trash + purge) instead of just the workspace. Refuses any configuration that owns a workspace other than the one you listed. -### Delete Sandboxes/Workspaces that were created by no longer active token id -This command can be used to delete all sandboxes and workspaces in a project that were created with a token that is no longer active in the project. -To also delete shared workspaces created by inactive tokens use the `--includeShared` option. -It will perform a dry run unleass the `--force/-f` option is applied. +Destroys: the **workspace only** by default. With `--with-configuration` the parent configuration and +therefore everything it owns. + +Behavior: +- Indexes every workspace of each project across all dev branches by workspace ID, and reports rows + it cannot find rather than failing silently. +- Skips, with a message and its own counter, any row whose schema does not match `expectedSchema`, + or whose login type is not a password login unless `--any-login-type` is given. +- With `--with-configuration` it first determines whether the configuration is live or already in the + trash, because that decides whether it needs one delete call (purge) or two (trash, then purge). + A live configuration is checked against the API's own list of its workspaces; a trashed one, whose + workspaces can no longer be listed, is checked against the project's live workspaces that still + reference it. Either way the configuration must own **exactly** the one workspace you listed. +- A configuration that is neither live nor in the trash is reported as an orphaned workspace and + skipped, with a hint to delete it without `--with-configuration`. +- If a purge is refused with `storage.components.cannotDeleteConfiguration`, that row is reported as + **failed**, not deleted: the configuration is probably still in the trash and its workspace and + backend user still exist, so it needs a re-check and a re-run. +- Prints a final summary counting deletions, failures, not-found rows and each skip reason + separately. + +### Delete Orphaned Workspaces command +Deletes workspaces of **one component** that were created **before a cutoff date**, in a single +project, across all its dev branches. The intended use case is workspaces left behind by failed +transformation jobs. +``` +php ./cli.php storage:delete-orphaned-workspaces [-f|--force] [-i|--ignore-backend-errors] [-m|--manage-token=TOKEN] [] [] +``` Arguments: -- Storage Token *required* -- Hostname suffix *optional* (default: keboola.com) +- storage-token (required) Storage API token for the target project. +- orphan-component (required) A **single** component ID matched exactly (e.g. `keboola.snowflake-transformation`). Pass `""` to match workspaces with an empty/blank component. +- hostname-suffix (optional, default: keboola.com) Connection host suffix. +- until-date (optional, default: `-1 month`) Cutoff as a `strtotime` expression; only workspaces created **before** it are selected. + +Note the argument order: `hostname-suffix` comes **before** `until-date`. Options: -- `--force/-f` -- `--includeShared` +- `--force` / `-f` Actually delete. Without it only reports. +- `--ignore-backend-errors` / `-i` Instead of deleting each workspace through the Storage API, collect the matched workspace IDs and drop them via the Manage API command `storage:workspace:drop-failed-workspaces-from-metadata`. Requires `--manage-token`. Use this for workspaces whose backend user survived a failed delete. This **replaces** the normal per-workspace delete, it is not additive. +- `--manage-token` / `-m` Super-admin Manage API token, required by `--ignore-backend-errors`. -- Run the command - ``` - php ./cli.php storage:delete-ownerless-workspaces [--force/-f] [--includeShared] - ``` -### Delete all sandboxes in a project -Bulk delete all sandboxes in a project (and their underlying storage workspaces). Dry-run by default. +Destroys: the **workspace only**. The parent configuration is left untouched. + +Behavior: +- Iterates all dev branches of the project and lists workspaces in each. +- Selects a workspace when `component` equals `orphan-component` **and** `created` is before `until-date`. +- Prints every workspace it skips together with the reason, so a dry run is auditable. +- Reports how many of the total workspaces found were deleted. + +### Delete Orphaned Workspaces in Organization command +The organization-scoped counterpart of `storage:delete-orphaned-workspaces`: same component + age +selection, but it walks every project of one or more organizations and mints its own short-lived +Storage token per project from a Manage token. + +``` +php ./cli.php manage:delete-organization-workspaces [-f|--force] [-c|--component=ID] [-g|--component-group=NAME] [-d|--until-date=DATE] [-H|--hostname-suffix=SUFFIX] +``` +Arguments: +- manage-token (required) Manage API token. +- organization-ids (required) **Comma-separated** list of organization IDs (e.g. `123,456`). + +Options: +- `--force` / `-f` Actually delete. Without it only reports. +- `--component` / `-c` A single component ID matched exactly, or `""` for empty/blank components. +- `--component-group` / `-g` A predefined group instead of a single component. Available: `transformations` (`keboola.snowflake-transformation`, `keboola.legacy-transformation`, `transformation`). +- `--until-date` / `-d` (default `-1 month`) Cutoff as a `strtotime` expression; only workspaces created before it are selected. +- `--hostname-suffix` / `-H` (default `keboola.com`) Connection host suffix. + +Exactly one of `--component` / `--component-group` is required; passing both is an error. +Note that unlike the project-scoped variant, the component, cutoff and host suffix are **options, +not positional arguments**. + +Destroys: the **workspace only**. The parent configuration is left untouched. + +Behavior: +- For each organization, lists its projects and creates a temporary Storage token per project + (skipping projects the token cannot access, with a warning). +- Iterates all dev branches per project and applies the same selection as the project-scoped command. +- Prints per-project, per-organization and final summaries, including a breakdown of skipped + workspaces by component. + +### Delete Sandboxes/Workspaces that were created by no longer active token id +Deletes sandboxes and workspaces in a project whose **owner is no longer an active user of the +project**. Use it after people leave a project or an organization. ``` -php cli.php storage:delete-project-sandboxes [--force/-f] [--includeShared] [] +php ./cli.php storage:delete-ownerless-workspaces [-f|--force] [--includeShared] [] ``` Arguments: -- storageToken (required) Storage API token for the target project. -- hostnameSuffix (optional, default: keboola.com) Connection host suffix (e.g. eu-central-1.keboola.com). +- storage-token (required) Storage API token for the target project. +- hostname-suffix (optional, default: keboola.com) Connection host suffix. Options: -- --force / -f Actually perform deletions. Without it the command just lists what would be deleted. -- --includeShared Include shared sandboxes; by default shared ones are skipped. +- `--force` / `-f` Actually delete. Without it only reports. +- `--includeShared` Also delete shared sessions and shared sandbox configurations. Skipped by default. + +Destroys: the **parent configuration** (trash + purge), which cascades to its workspace and backend +user. This is the correct behaviour for sandboxes, where the configuration is the sandbox itself. Behavior: -- Lists all sandboxes via Sandboxes API. -- (Unless --includeShared) skips those marked shared. -- For DB-type sandboxes deletes associated Storage workspace (physicalId or staging workspace) first, then deletes sandbox. -- Prints summary: X sandboxes deleted and Y storage workspaces deleted. +- Lists the project's tokens to build the set of active user IDs and active token IDs. +- **SQL sessions:** lists editor-service sessions and selects those whose `userId` is not an active + user. Deletes the session's configuration twice (trash, then purge). If the purge is refused with + `storage.components.cannotDeleteConfiguration`, deletes the editor session instead. +- **Python/R sandboxes:** lists apps from the sandboxes service and selects those whose + `keboola.sandboxes` configuration was created by a token that is no longer active. Queues a + `keboola.sandboxes` delete job for each; if queueing fails it falls back to deleting the app + directly and then purging its configuration, so nothing is left half-deleted. +- Note the asymmetry: sessions are matched by **user ID** (stable), sandbox configurations by + **creator token ID**, which is re-issued whenever a user leaves and rejoins a project. A user who + left and came back keeps their SQL sessions but loses their old Python/R sandboxes. + +### Delete ownerless Sandboxes/Workspaces across an organization +The organization-scoped counterpart of `storage:delete-ownerless-workspaces`. The selection and +deletion logic is identical; this variant iterates every project of one organization and mints its +own short-lived Storage token per project, and prints a per-project summary at the end. + +``` +php ./cli.php manage:delete-organization-ownerless-workspaces [-f|--force] [--includeShared] [] +``` +Arguments: +- manage-token (required) Manage API token. +- organization-id (required) A **single** numeric organization ID. +- hostname-suffix (optional, default: keboola.com) Connection host suffix. -### Delete multiple project workspaces access projects -Delete specific Snowflake sandboxes and storage workspaces across multiple projects by workspace schema names. +Options: +- `--force` / `-f` Actually delete. Without it only reports. +- `--includeShared` Also delete shared sessions and shared sandbox configurations. Skipped by default. + +Destroys: the same as the project-scoped variant - the **parent configuration** (trash + purge), +cascading to its workspace and backend user. + +### Delete multiple project workspaces across projects +Deletes workspaces listed explicitly in a CSV, across multiple projects, matched by **workspace +schema name**. ``` php cli.php manage:mass-delete-project-workspaces [-f|--force] ``` Arguments: -- stack-suffix (required) Stack host suffix (e.g. keboola.com, eu-central-1.keboola.com). -- source-file (required) CSV without header, two columns per line: ,. Example: +- stack-suffix (required) Stack host suffix (e.g. `keboola.com`, `eu-central-1.keboola.com`). +- source-file (required) CSV **without header**, exactly two columns per line: `,`. Example: ``` 12345,WORKSPACE_111111111 98765,WORKSPACE_222222222 ``` Options: -- --force / -f Create and wait for delete jobs and actually delete matching storage workspaces. Without it the command only reports (dry-run). +- `--force` / `-f` Actually delete. Without it only reports. + +Destroys: the **parent configuration** (trash + purge), which cascades to its workspace and backend +user. It does not call `deleteWorkspace` at all. Behavior: -- Builds a map of projectId => list of workspace schemas to delete; validates schema names start with WORKSPACE_. -- For each project it interactively prompts (STDIN) for that project's STORAGE token (one-by-one) so tokens aren't stored in file. -- Enumerates all dev branches, lists sandboxes per branch, matches schemas, and (force) queues delete jobs (via queue API) for sandboxes; waits until the jobs finish. -- Then enumerates Storage workspaces per branch and deletes any whose schema is still pending. -- Any schemas not found are printed for manual follow-up. -- Currently targeted at Snowflake (SNFLK) workspaces only. +- Builds a `projectId => [schemas]` map and validates that every schema starts with `WORKSPACE_`. +- For each project it **prompts interactively on STDIN** for that project's Storage token, so tokens + are never kept in a file. This makes the command unsuitable for large unattended batches. +- Resolves each schema through the project's **editor-service sessions**, then deletes the matching + session's configuration twice (trash, then purge), tolerating + `storage.components.cannotDeleteConfiguration` on the purge. +- Because the lookup goes through editor sessions, **any workspace without a session cannot be + found**. Those schemas are printed at the end as "not found (are deleted or need to be deleted + manually)" - read that list, it is where the real leftovers end up. +- Targeted at Snowflake (SNFLK) workspaces. ## Project manipulation diff --git a/cli.php b/cli.php index 2db2c82..2908e23 100644 --- a/cli.php +++ b/cli.php @@ -7,7 +7,9 @@ use Keboola\Console\Command\AddFeature; use Keboola\Console\Command\CleanupLeakedTestFeatures; use Keboola\Console\Command\AllStacksIterator; +use Keboola\Console\Command\CheckProjectWorkspacesState; use Keboola\Console\Command\DeleteProjects; +use Keboola\Console\Command\DeleteProjectWorkspacesById; use Keboola\Console\Command\DeleteStorageBackend; use Keboola\Console\Command\DeleteOrganizationOrphanedWorkspaces; use Keboola\Console\Command\DeleteOrganizationOwnerlessWorkspaces; @@ -61,6 +63,8 @@ $application->add(new ReactivateSchedules()); $application->add(new DescribeOrganizationWorkspaces()); $application->add(new MassDeleteProjectWorkspaces()); +$application->add(new CheckProjectWorkspacesState()); +$application->add(new DeleteProjectWorkspacesById()); $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..16d1118 --- /dev/null +++ b/src/Keboola/Console/Command/CheckProjectWorkspacesState.php @@ -0,0 +1,512 @@ + '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', + 'configState', + 'configName', + 'configCreated', + 'configCreator', + 'lastJobStatus', + 'lastJobCreated', + 'lastJobEnd', + '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, + ]); + + // collect live workspaces (by schema) across all branches first, so that the + // config listing below also covers components referenced only by live workspaces + /** @var array $liveWorkspacesBySchema */ + $liveWorkspacesBySchema = []; + /** @var array $branchClients */ + $branchClients = []; + + $devBranches = new DevBranches($storageClient); + $branches = $devBranches->listBranches(); + $defaultBranchId = null; + /** @var array $branchNames */ + $branchNames = []; + foreach ($branches as $branch) { + assert(is_int($branch['id'])); + assert(is_string($branch['name'])); + $branchNames[$branch['id']] = $branch['name']; + if (($branch['isDefault'] ?? false) === true) { + $defaultBranchId = $branch['id']; + } + } + foreach ($branches 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, + ]); + $branchClients[$branchId] = $branchClient; + + $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'] ?? '', + ]; + } + } + + $componentIds = array_map( + fn(array $row): ?string => $row['componentId'], + $rows, + ); + foreach ($rows as $row) { + if (isset($liveWorkspacesBySchema[$row['schema']])) { + $componentIds[] = $liveWorkspacesBySchema[$row['schema']]['componentId']; + } + } + $componentIds = array_values(array_unique(array_filter($componentIds))); + + // collect live and trashed configs of all referenced components, per branch + // (a config deleted in one branch may still exist as a copy with the same id + // in another branch — the states must not be merged across branches) + /** @var array> $liveConfigs */ + $liveConfigs = []; + /** @var array> $trashedConfigs */ + $trashedConfigs = []; + + foreach ($branchClients as $branchId => $branchClient) { + $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']; + $creatorToken = $configuration['creatorToken'] ?? []; + assert(is_array($creatorToken)); + $detail = [ + 'name' => is_string($configuration['name'] ?? null) ? $configuration['name'] : '', + 'created' => is_string($configuration['created'] ?? null) ? $configuration['created'] : '', + 'creator' => is_string($creatorToken['description'] ?? null) + ? $creatorToken['description'] : '', + ]; + if ($isDeleted) { + $trashedConfigs[$key][$branchId] = $detail; + } else { + $liveConfigs[$key][$branchId] = $detail; + } + } + } + } + } + + $queueClient = new JobQueueClient($serviceClient->getQueueUrl(), $storageToken['token']); + + // sanity check: verify job search works at all for this project, so that + // empty lastJob columns can be trusted as "config really has no job history" + try { + $anyJobs = $queueClient->listJobs( + (new ListJobsOptions()) + ->setCreatedTimeFrom(new \DateTimeImmutable('2015-01-01T00:00:00+00:00')) + ->setSortBy('id') + ->setSortOrder(ListJobsOptions::SORT_ORDER_DESC) + ->setLimit(1) + ); + $output->writeln(count($anyJobs) > 0 + ? sprintf( + ' job search OK, most recent job in project: %s (%s, %s)', + $anyJobs[0]->component, + $anyJobs[0]->status, + $anyJobs[0]->createdTime->format(DATE_ATOM) + ) + : ' job search returned no jobs for the whole project — empty lastJob columns are inconclusive'); + } catch (\Throwable $e) { + $output->writeln(sprintf(' job search sanity check failed: %s', $e->getMessage())); + } + + 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']); + } + $configDetail = null; + $lastJob = null; + if ($live['componentId'] === '' || $live['configurationId'] === '') { + $configState = ''; + } else { + $key = $live['componentId'] . '/' . $live['configurationId']; + // the config state is evaluated in the workspace's own branch; + // copies in other branches only go to the note + $eval = $this->evaluateConfig( + $liveConfigs[$key] ?? [], + $trashedConfigs[$key] ?? [], + $live['branchId'], + $branchNames + ); + $configState = $eval['state']; + $configDetail = $eval['detail']; + $note = trim($note . ' ' . $eval['note']); + $lastJob = $this->fetchLastJob($queueClient, $live['componentId'], $live['configurationId'], $note); + } + $this->writeReportRow($report, $statusCounts, $projectId, $row, self::STATUS_LIVE, $live, $note, $configState, $configDetail, $lastJob); + 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']; + $eval = $this->evaluateConfig( + $liveConfigs[$key] ?? [], + $trashedConfigs[$key] ?? [], + $defaultBranchId, + $branchNames + ); + $note = $eval['note']; + $lastJob = $this->fetchLastJob($queueClient, $row['componentId'], $row['configurationId'], $note); + $status = match ($eval['state']) { + self::CONFIG_STATE_IN_TRASH => self::STATUS_CONFIG_IN_TRASH, + self::CONFIG_STATE_LIVE => self::STATUS_CONFIG_LIVE_WORKSPACE_GONE, + default => self::STATUS_PURGED_OR_ORPHAN, + }; + $this->writeReportRow($report, $statusCounts, $projectId, $row, $status, null, $note, '', $eval['detail'], $lastJob); + } + + $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 $statusKey => $count) { + $status = explode(' ', $statusKey)[0]; + $output->writeln(sprintf(' - %s: %d (%s)', $statusKey, $count, self::SUGGESTED_ACTION[$status])); + } + + return 0; + } + + /** + * Evaluates config existence in the given branch; presence in other branches is + * reported via note only (branches have independent copies under the same id). + * + * @param array $liveIn + * @param array $trashedIn + * @param array $branchNames + * @return array{state: string, detail: array{name: string, created: string, creator: string}|null, note: string} + */ + private function evaluateConfig(array $liveIn, array $trashedIn, ?int $branchId, array $branchNames): array + { + if ($branchId !== null && isset($liveIn[$branchId])) { + $state = self::CONFIG_STATE_LIVE; + } elseif ($branchId !== null && isset($trashedIn[$branchId])) { + $state = self::CONFIG_STATE_IN_TRASH; + } else { + $state = self::CONFIG_STATE_NOT_FOUND; + } + + $detail = null; + if ($branchId !== null) { + $detail = $liveIn[$branchId] ?? $trashedIn[$branchId] ?? null; + } + if ($detail === null && count($liveIn) > 0) { + $detail = $liveIn[array_key_first($liveIn)]; + } + if ($detail === null && count($trashedIn) > 0) { + $detail = $trashedIn[array_key_first($trashedIn)]; + } + + $otherBranches = []; + foreach ($liveIn as $otherBranchId => $ignored) { + if ($otherBranchId !== $branchId) { + $otherBranches[] = sprintf('live in branch %d (%s)', $otherBranchId, $branchNames[$otherBranchId] ?? '?'); + } + } + foreach ($trashedIn as $otherBranchId => $ignored) { + if ($otherBranchId !== $branchId) { + $otherBranches[] = sprintf('in trash in branch %d (%s)', $otherBranchId, $branchNames[$otherBranchId] ?? '?'); + } + } + $note = count($otherBranches) > 0 ? 'config also ' . implode(', ', $otherBranches) : ''; + + return ['state' => $state, 'detail' => $detail, 'note' => $note]; + } + + /** + * @return array{status: string, created: string, end: string}|null + */ + private function fetchLastJob( + JobQueueClient $queueClient, + string $componentId, + string $configurationId, + string &$note + ): ?array { + try { + $jobs = $queueClient->listJobs( + (new ListJobsOptions()) + ->setComponents([$componentId]) + ->setConfigIds([$configurationId]) + // explicit wide window so a possible server-side default window + // does not hide old jobs + ->setCreatedTimeFrom(new \DateTimeImmutable('2015-01-01T00:00:00+00:00')) + ->setSortBy('id') + ->setSortOrder(ListJobsOptions::SORT_ORDER_DESC) + ->setLimit(1) + ); + } catch (\Throwable $e) { + $note = trim($note . ' last-job lookup failed: ' . $e->getMessage()); + return null; + } + if (count($jobs) === 0) { + return null; + } + $job = $jobs[0]; + assert($job instanceof Job); + return [ + 'status' => $job->status, + 'created' => $job->createdTime->format(DATE_ATOM), + 'end' => $job->endTime !== null ? $job->endTime->format(DATE_ATOM) : '', + ]; + } + + /** + * @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 + * @param array{name: string, created: string, creator: string}|null $configDetail + * @param array{status: string, created: string, end: string}|null $lastJob + */ + private function writeReportRow( + CsvFile $report, + array &$statusCounts, + string $projectId, + array $row, + string $status, + ?array $live = null, + string $note = '', + string $configState = '', + ?array $configDetail = null, + ?array $lastJob = null + ): void { + $statusKey = $configState !== '' ? sprintf('%s (config %s)', $status, $configState) : $status; + $statusCounts[$statusKey] = ($statusCounts[$statusKey] ?? 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'] ?? '', + $configState, + $configDetail['name'] ?? '', + $configDetail['created'] ?? '', + $configDetail['creator'] ?? '', + $lastJob['status'] ?? '', + $lastJob['created'] ?? '', + $lastJob['end'] ?? '', + $note, + ]); + } +} diff --git a/src/Keboola/Console/Command/DeleteProjectWorkspacesById.php b/src/Keboola/Console/Command/DeleteProjectWorkspacesById.php new file mode 100644 index 0000000..8fcf2f7 --- /dev/null +++ b/src/Keboola/Console/Command/DeleteProjectWorkspacesById.php @@ -0,0 +1,412 @@ +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) + $purgeRefused = false; + for ($i = 0; $i < $deleteCallCount; $i++) { + try { + $components->deleteConfiguration($component, $configurationId); + } catch (StorageClientException $e) { + if ($e->getStringCode() !== 'storage.components.cannotDeleteConfiguration') { + throw $e; + } + $purgeRefused = true; + } + } + if ($purgeRefused) { + $output->writeln(sprintf( + 'WARNING: %s refused with "cannotDeleteConfiguration" for %s' + . ' — configuration likely still sits in trash and its workspace + backend user' + . ' still exist. Re-check and re-run later.', + $actionDescription, + $description, + )); + $failed++; + continue; + } + $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; + } +}