From c313238eced4befde713e2696eb2f208bbe8ad0b Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Thu, 30 Jul 2026 17:14:15 +0200 Subject: [PATCH 1/2] docs: document workspace-deletion commands and when to use which The "Workspaces and sandboxes" section had drifted from the code and gave no guidance on choosing between the commands, which all look interchangeable from their names but destroy different things. - Explain up front what each command actually destroys: deleting a workspace removes the workspace record and backend user, deleting a configuration (trash + purge) cascades to its workspaces. That distinction is safe for keboola.sandboxes, where the configuration is the sandbox, and destructive for transformation components, where it is the user's code. - Add a "Which command should I use?" table plus rules of thumb, including that the orphaned/ownerless pairs are the same logic at project and organization scope, and that "orphaned" selects purely by component + age. - Document manage:delete-organization-ownerless-workspaces, which existed but was missing from the README entirely. - Drop the storage:delete-project-sandboxes section: the command was removed in a30168d when sandboxes-api was replaced by editor-service sessions, but its documentation stayed behind. - Correct the signatures that no longer matched the code: storage:delete-orphaned-workspaces takes hostname-suffix before until-date and matches a single component, not a list, and has --ignore-backend-errors / --manage-token which were undocumented; manage:delete-organization-workspaces takes component, cutoff and host suffix as options rather than positional arguments, and accepts a comma-separated list of organization IDs; manage:describe-organization-workspaces writes an activeUser column that was missing from the documented header. - Correct manage:mass-delete-project-workspaces: it resolves schemas through editor sessions and purges the parent configuration, it does not queue delete jobs and wait for them. Call out that workspaces without an editor session cannot be found, so its "not found" list is the meaningful output. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 256 ++++++++++++++++++++++++++++++++++++++---------------- 1 file changed, 180 insertions(+), 76 deletions(-) diff --git a/README.md b/README.md index 006ae71..eb1b4d3 100644 --- a/README.md +++ b/README.md @@ -103,120 +103,224 @@ You can add a project feature to all the project templates available on the stac ## Workspaces and sandboxes +### 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**, as a CSV report | `manage:describe-organization-workspaces` | +| 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` | `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 `manage:describe-organization-workspaces`.** It is read-only and reports component, + creator and creation date per workspace, which is what you need in order to pick the right command + and the right filter below. +- 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. + +### 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. + +``` +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). + +Destroys: nothing, this command is read-only. + +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. + ### 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. +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. -- Create a Storage token +``` +php ./cli.php storage:delete-orphaned-workspaces [-f|--force] [-i|--ignore-backend-errors] [-m|--manage-token=TOKEN] [] [] +``` +Arguments: +- 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. -- Run the command - ``` - php ./cli.php storage:delete-orphaned-workspaces [--force/-f] - ``` +Note the argument order: `hostname-suffix` comes **before** `until-date`. -### 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. +Options: +- `--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`. -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. +Destroys: the **workspace only**. The parent configuration is left untouched. -- Create a Storage token +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. -- Run the command - ``` - php ./cli.php manage:delete-organization-workspaces [--force/-f] - ``` +### 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. -### 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' +``` +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* -- Organisation Id *required* -- Output File *required* -- Hostname suffix *optional* (default: keboola.com) +- manage-token (required) Manage API token. +- organization-ids (required) **Comma-separated** list of organization IDs (e.g. `123,456`). -- Run the command - ``` - php ./cli.php manage:describe-organization-workspaces - ``` +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 -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. +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-ownerless-workspaces [-f|--force] [--includeShared] [] +``` Arguments: -- Storage Token *required* -- Hostname suffix *optional* (default: keboola.com) +- storage-token (required) Storage API token for the target project. +- hostname-suffix (optional, default: keboola.com) Connection host suffix. Options: -- `--force/-f` -- `--includeShared` +- `--force` / `-f` Actually delete. Without it only reports. +- `--includeShared` Also delete shared sessions and shared sandbox configurations. Skipped by default. -- 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 **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. -``` -php cli.php storage:delete-project-sandboxes [--force/-f] [--includeShared] [] +Behavior: +- 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: -- storageToken (required) Storage API token for the target project. -- hostnameSuffix (optional, default: keboola.com) Connection host suffix (e.g. eu-central-1.keboola.com). +- manage-token (required) Manage API token. +- organization-id (required) A **single** numeric organization ID. +- 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. -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. +Destroys: the same as the project-scoped variant - the **parent configuration** (trash + purge), +cascading to its workspace and backend user. -### Delete multiple project workspaces access projects -Delete specific Snowflake sandboxes and storage workspaces across multiple projects by workspace schema names. +### 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 From 124818f6ad356a11a15121894797eb18190aba47 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Mon, 3 Aug 2026 15:52:14 +0200 Subject: [PATCH 2/2] feat: add workspace state check and delete-by-id commands Consolidates the outstanding workspace tooling into this branch, replacing PRs #92, #98 and #100. Adds two commands and documents them; no existing command is modified. manage:check-project-workspaces-state (read-only) Triage step for a known list of workspaces: reports whether each is still live and whether its configuration is live, trashed or gone, and suggests the follow-up command. Configuration state is resolved per branch, because branches hold independent copies under the same id and merging them misclassifies a config that is live in one branch and trashed in another. Also reports each configuration's most recent job from the Queue API, which is usually what decides whether it is still in use, and runs a sanity query for any job in the project first so that blank lastJob columns are not misread as "never used". manage:delete-project-workspaces-by-id List-driven deletion with layered guards: password-login only unless --any-login-type, optional expected-schema verification, and configuration deletion only behind --with-configuration and only when that configuration owns exactly the listed workspace. Deletes the workspace without touching the configuration by default. A purge refused with storage.components.cannotDeleteConfiguration is counted as failed rather than deleted, since in that case the configuration is still in the trash and its workspace and backend user still exist. README gains entries for both, and the "Which command should I use?" table now points at the by-id command as the preferred list-driven option over manage:mass-delete-project-workspaces, which needs an interactively pasted token per project and always purges the configuration. Co-Authored-By: Claude Opus 5 (1M context) --- README.md | 104 +++- cli.php | 4 + .../Command/CheckProjectWorkspacesState.php | 512 ++++++++++++++++++ .../Command/DeleteProjectWorkspacesById.php | 412 ++++++++++++++ 4 files changed, 1027 insertions(+), 5 deletions(-) create mode 100644 src/Keboola/Console/Command/CheckProjectWorkspacesState.php create mode 100644 src/Keboola/Console/Command/DeleteProjectWorkspacesById.php diff --git a/README.md b/README.md index eb1b4d3..f95a596 100644 --- a/README.md +++ b/README.md @@ -132,19 +132,28 @@ delete). Nothing that works through workspaces or editor sessions can see those | What you have / want to do | Command | | --- | --- | -| Just **see what is there**, as a CSV report | `manage:describe-organization-workspaces` | +| 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` | `manage:mass-delete-project-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 `manage:describe-organization-workspaces`.** It is read-only and reports component, - creator and creation date per workspace, which is what you need in order to pick the right command - and the right filter below. +- **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. @@ -177,6 +186,91 @@ projectId,projectName,branchId,branchName,componentId,configurationId,creatorEma `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. +### 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. + +``` +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. + +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) 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. + +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. + +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 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; + } +}