From 35f5a39b998252f2e9c57fada34e25b2ed631f64 Mon Sep 17 00:00:00 2001 From: Jiri Semmler Date: Mon, 20 Jul 2026 13:30:54 +0200 Subject: [PATCH] Add manage:cleanup-leaked-test-features command One-off cleanup of features leaked by connection manage E2E tests (tests created randomly-named features and never deleted them; fixed in keboola/connection#7914). Matches features by test name prefixes, dry-run by default, refuses to run against production hosts. Co-Authored-By: Claude Fable 5 --- cli.php | 2 + .../Command/CleanupLeakedTestFeatures.php | 123 ++++++++++++++++++ 2 files changed, 125 insertions(+) create mode 100644 src/Keboola/Console/Command/CleanupLeakedTestFeatures.php diff --git a/cli.php b/cli.php index c7f7a9f..2db2c82 100644 --- a/cli.php +++ b/cli.php @@ -5,6 +5,7 @@ require __DIR__.'/vendor/autoload.php'; use Keboola\Console\Command\AddFeature; +use Keboola\Console\Command\CleanupLeakedTestFeatures; use Keboola\Console\Command\AllStacksIterator; use Keboola\Console\Command\DeleteProjects; use Keboola\Console\Command\DeleteStorageBackend; @@ -46,6 +47,7 @@ $application->add(new MassProjectEnableDynamicBackends()); $application->add(new MigrateDataAppsOrchestratorTasks()); $application->add(new AddFeature()); +$application->add(new CleanupLeakedTestFeatures()); $application->add(new AllStacksIterator()); $application->add(new LineageEventsExport()); $application->add(new QueueMassTerminateJobs()); diff --git a/src/Keboola/Console/Command/CleanupLeakedTestFeatures.php b/src/Keboola/Console/Command/CleanupLeakedTestFeatures.php new file mode 100644 index 0000000..2a57902 --- /dev/null +++ b/src/Keboola/Console/Command/CleanupLeakedTestFeatures.php @@ -0,0 +1,123 @@ +setName('manage:cleanup-leaked-test-features') + ->setDescription('Delete features leaked by connection E2E tests (matched by test name prefixes)') + ->addArgument('token', InputArgument::REQUIRED, 'manage token (super-admin)') + ->addArgument('url', InputArgument::REQUIRED, 'Stack URL') + ->addOption('force', 'f', InputOption::VALUE_NONE, 'Will actually do the work, otherwise it\'s dry run') + ; + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $apiToken = $input->getArgument('token'); + assert(is_string($apiToken)); + $apiUrl = $input->getArgument('url'); + assert(is_string($apiUrl)); + $force = (bool) $input->getOption('force'); + + if (in_array(parse_url($apiUrl, PHP_URL_HOST), self::PRODUCTION_HOSTS, true)) { + $output->writeln(sprintf('Refusing to run against production host %s.', $apiUrl)); + return 1; + } + + $client = new Client([ + 'url' => $apiUrl, + 'token' => $apiToken, + ]); + + $matched = []; + foreach (['admin', 'project', 'global'] as $type) { + $output->write(sprintf('Listing "%s" features... ', $type)); + $features = $client->listFeatures(['type' => $type]); + $matchedForType = 0; + foreach ($features as $feature) { + foreach (self::LEAKED_NAME_PREFIXES as $prefix) { + if (strpos((string) $feature['name'], $prefix) === 0) { + $matched[] = $feature; + $matchedForType++; + break; + } + } + } + $output->writeln(sprintf('%d total, %d matched', count($features), $matchedForType)); + } + + if ($matched === []) { + $output->writeln('No leaked test features found.'); + return 0; + } + + $output->writeln(sprintf('Matched %d leaked test features on %s:', count($matched), $apiUrl)); + $shown = $output->isVerbose() ? $matched : array_slice($matched, 0, self::LIST_SAMPLE_SIZE); + foreach ($shown as $feature) { + $output->writeln(sprintf(' [%s] #%d %s', $feature['type'], $feature['id'], $feature['name'])); + } + if (count($matched) > count($shown)) { + $output->writeln(sprintf(' ... and %d more (use -v to list all)', count($matched) - count($shown))); + } + + if (!$force) { + $output->writeln(''); + $output->writeln('Command was run in dry-run mode. To actually apply changes run it with --force flag.'); + return 0; + } + + $deleted = 0; + $failed = 0; + foreach ($matched as $i => $feature) { + try { + $output->writeln('Dropping feature'); + + $client->removeFeature($feature['id']); + $deleted++; + } catch (ClientException $e) { + $failed++; + $output->writeln(sprintf( + ' FAILED to delete #%d %s: %s', + $feature['id'], + $feature['name'], + $e->getMessage() + )); + } + if (($i + 1) % self::PROGRESS_EVERY === 0) { + $output->writeln(sprintf(' %d/%d processed (%d deleted, %d failed)', $i + 1, count($matched), $deleted, $failed)); + } + } + + $output->writeln(''); + $output->writeln(sprintf('Deleted %d features, %d failed.', $deleted, $failed)); + return $failed > 0 ? 1 : 0; + } +}