diff --git a/.github/workflows/cleanup-test-resources.yml b/.github/workflows/cleanup-test-resources.yml
new file mode 100644
index 00000000..6d405cb5
--- /dev/null
+++ b/.github/workflows/cleanup-test-resources.yml
@@ -0,0 +1,76 @@
+name: Cleanup Test Resources
+
+permissions:
+ contents: read
+
+on:
+ # Scheduled execution - daily at 2 AM UTC
+ schedule:
+ - cron: '0 2 * * *'
+
+ # Manual trigger with optional inputs
+ workflow_dispatch:
+ inputs:
+ age_threshold_days:
+ description: 'Minimum age in days for resources to be deleted'
+ required: false
+ default: '1'
+ type: number
+ dry_run:
+ description: 'Preview deletions without executing (dry-run mode)'
+ required: false
+ default: false
+ type: boolean
+
+jobs:
+ cleanup:
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 8
+ uses: actions/setup-java@v4
+ with:
+ distribution: 'temurin'
+ java-version: '8'
+ cache: 'gradle'
+
+ - name: Build project
+ run: ./gradlew build -x test
+
+ - name: Run cleanup utility
+ env:
+ PINECONE_API_KEY: ${{ secrets.PINECONE_API_KEY }}
+ run: |
+ # Determine parameters based on trigger type
+ if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
+ AGE_THRESHOLD=${{ inputs.age_threshold_days }}
+ DRY_RUN=${{ inputs.dry_run }}
+ else
+ # Scheduled run uses default values
+ AGE_THRESHOLD=1
+ DRY_RUN=false
+ fi
+
+ # Build command with arguments
+ ARGS="--age-threshold-days $AGE_THRESHOLD"
+ if [ "$DRY_RUN" = "true" ]; then
+ ARGS="$ARGS --dry-run"
+ fi
+
+ echo "Running cleanup with: $ARGS"
+
+ # Run the cleanup utility
+ java -cp "build/libs/*:build/classes/java/main" \
+ io.pinecone.helpers.IndexCleanupUtility $ARGS
+
+ - name: Summary
+ if: always()
+ run: |
+ if [ "${{ job.status }}" = "success" ]; then
+ echo "✅ Cleanup completed successfully"
+ else
+ echo "❌ Cleanup failed - check logs for details"
+ fi
diff --git a/build.gradle b/build.gradle
index 738a537a..1c055651 100644
--- a/build.gradle
+++ b/build.gradle
@@ -70,6 +70,7 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat
import org.gradle.api.tasks.testing.logging.TestLogEvent
tasks.withType(Test) {
+ systemProperty "net.bytebuddy.experimental", "true"
testLogging {
// set options for log level LIFECYCLE
events TestLogEvent.FAILED,
diff --git a/src/main/java/io/pinecone/helpers/IndexCleanupUtility.java b/src/main/java/io/pinecone/helpers/IndexCleanupUtility.java
index 72d73d3d..c71cf878 100644
--- a/src/main/java/io/pinecone/helpers/IndexCleanupUtility.java
+++ b/src/main/java/io/pinecone/helpers/IndexCleanupUtility.java
@@ -1,38 +1,302 @@
package io.pinecone.helpers;
import io.pinecone.clients.Pinecone;
+import org.openapitools.db_control.client.model.CollectionList;
+import org.openapitools.db_control.client.model.CollectionModel;
+import org.openapitools.db_control.client.model.IndexList;
import org.openapitools.db_control.client.model.IndexModel;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import java.util.ArrayList;
+import java.util.List;
+
+/**
+ * Utility for cleaning up Pinecone indexes and collections.
+ *
+ * This utility can be used to clean up resources in a Pinecone project, with support for:
+ * - Deleting all indexes and collections
+ * - Dry-run mode to preview deletions without executing them
+ * - Automatic handling of deletion protection
+ * - Age-based filtering (when timestamp information becomes available)
+ *
+ * Command-line arguments:
+ * --age-threshold-days <number> - Minimum age in days for resources to be deleted (default: 1)
+ * --dry-run - Preview deletions without actually deleting resources
+ *
+ * Example usage:
+ *
{@code
+ * java io.pinecone.helpers.IndexCleanupUtility --age-threshold-days 2 --dry-run
+ * }
+ */
public class IndexCleanupUtility {
private static final Logger logger = LoggerFactory.getLogger(IndexCleanupUtility.class);
+ private final Pinecone pinecone;
+ private final int ageThresholdDays;
+ private final boolean dryRun;
+
+ /**
+ * Constructs a new IndexCleanupUtility.
+ *
+ * @param pinecone The Pinecone client instance
+ * @param ageThresholdDays Minimum age in days for resources to be deleted
+ * @param dryRun If true, preview deletions without executing them
+ */
+ public IndexCleanupUtility(Pinecone pinecone, int ageThresholdDays, boolean dryRun) {
+ this.pinecone = pinecone;
+ this.ageThresholdDays = ageThresholdDays;
+ this.dryRun = dryRun;
+ }
+
+ /**
+ * Main entry point for the cleanup utility.
+ *
+ * @param args Command-line arguments
+ */
public static void main(String[] args) {
try {
- logger.info("Starting Pinecone index cleanup...");
- Pinecone pinecone = new Pinecone.Builder(System.getenv("PINECONE_API_KEY")).build();
+ // Parse command-line arguments
+ int ageThresholdDays = 1; // Default: 1 day
+ boolean dryRun = false;
- for(IndexModel model : pinecone.listIndexes().getIndexes()) {
- String indexName = model.getName();
- if(model.getDeletionProtection().equals("enabled")) {
+ for (int i = 0; i < args.length; i++) {
+ if ("--age-threshold-days".equals(args[i]) && i + 1 < args.length) {
try {
- model.getSpec().getIndexModelPodBased();
- pinecone.configurePodsIndex(indexName, "disabled");
- } catch (ClassCastException e) {
- // Not a pod-based index, continue
+ ageThresholdDays = Integer.parseInt(args[i + 1]);
+ i++; // Skip the next argument since we've consumed it
+ } catch (NumberFormatException e) {
+ logger.error("Invalid value for --age-threshold-days: {}", args[i + 1]);
+ printUsage();
+ System.exit(1);
}
- pinecone.configureServerlessIndex(indexName, "disabled", null, null);
+ } else if ("--dry-run".equals(args[i])) {
+ dryRun = true;
+ } else {
+ logger.warn("Unknown argument: {}", args[i]);
}
- Thread.sleep(5000);
- pinecone.deleteIndex(indexName);
}
- logger.info("Index cleanup completed");
+ logger.info("Starting Pinecone resource cleanup...");
+ logger.info("Age threshold: {} days", ageThresholdDays);
+ logger.info("Dry-run mode: {}", dryRun);
+
+ // Initialize Pinecone client
+ String apiKey = System.getenv("PINECONE_API_KEY");
+ if (apiKey == null || apiKey.isEmpty()) {
+ logger.error("PINECONE_API_KEY environment variable is not set");
+ System.exit(1);
+ }
+
+ Pinecone pinecone = new Pinecone.Builder(apiKey).build();
+ IndexCleanupUtility utility = new IndexCleanupUtility(pinecone, ageThresholdDays, dryRun);
+
+ // Execute cleanup
+ CleanupResult result = utility.cleanup();
+
+ // Log summary
+ logger.info("=== Cleanup Summary ===");
+ logger.info("Indexes processed: {}", result.getIndexesProcessed());
+ logger.info("Indexes deleted: {}", result.getIndexesDeleted());
+ logger.info("Indexes failed: {}", result.getIndexesFailed());
+ logger.info("Collections processed: {}", result.getCollectionsProcessed());
+ logger.info("Collections deleted: {}", result.getCollectionsDeleted());
+ logger.info("Collections failed: {}", result.getCollectionsFailed());
+
+ if (dryRun) {
+ logger.info("DRY-RUN MODE: No resources were actually deleted");
+ }
+
+ logger.info("Cleanup completed");
+
+ // Exit with error code if any deletions failed
+ if (result.getIndexesFailed() > 0 || result.getCollectionsFailed() > 0) {
+ System.exit(1);
+ }
} catch (Exception e) {
logger.error("Error during cleanup: {}", e.getMessage(), e);
System.exit(1);
}
}
+
+ private static void printUsage() {
+ System.err.println("Usage: java io.pinecone.helpers.IndexCleanupUtility [OPTIONS]");
+ System.err.println("Options:");
+ System.err.println(" --age-threshold-days Minimum age in days for resources to be deleted (default: 1)");
+ System.err.println(" --dry-run Preview deletions without executing them");
+ }
+
+ /**
+ * Executes the cleanup operation, deleting indexes and collections that match the criteria.
+ *
+ * @return A CleanupResult containing statistics about the cleanup operation
+ * @throws Exception if an error occurs during cleanup
+ */
+ public CleanupResult cleanup() throws Exception {
+ CleanupResult result = new CleanupResult();
+
+ // Clean up indexes
+ logger.info("Listing indexes...");
+ IndexList indexList = pinecone.listIndexes();
+ List indexes = indexList != null ? indexList.getIndexes() : null;
+ if (indexes == null) {
+ indexes = new ArrayList<>();
+ }
+ logger.info("Found {} indexes", indexes.size());
+
+ for (IndexModel index : indexes) {
+ result.incrementIndexesProcessed();
+ try {
+ boolean deletionInitiated = cleanupIndex(index);
+ if (deletionInitiated) {
+ result.incrementIndexesDeleted();
+ }
+ } catch (Exception e) {
+ logger.error("Failed to delete index {}: {}", index.getName(), e.getMessage(), e);
+ result.incrementIndexesFailed();
+ }
+ }
+
+ // Clean up collections
+ logger.info("Listing collections...");
+ CollectionList collectionList = pinecone.listCollections();
+ List collections = collectionList != null ? collectionList.getCollections() : null;
+ if (collections == null) {
+ collections = new ArrayList<>();
+ }
+ logger.info("Found {} collections", collections.size());
+
+ for (CollectionModel collection : collections) {
+ result.incrementCollectionsProcessed();
+ try {
+ boolean deletionInitiated = cleanupCollection(collection);
+ if (deletionInitiated) {
+ result.incrementCollectionsDeleted();
+ }
+ } catch (Exception e) {
+ logger.error("Failed to delete collection {}: {}", collection.getName(), e.getMessage(), e);
+ result.incrementCollectionsFailed();
+ }
+ }
+
+ return result;
+ }
+
+ /**
+ * Cleans up a single index.
+ *
+ * @param index The index to clean up
+ * @throws Exception if an error occurs during cleanup
+ */
+ private boolean cleanupIndex(IndexModel index) throws Exception {
+ String indexName = index.getName();
+ String status = index.getStatus() != null && index.getStatus().getState() != null
+ ? index.getStatus().getState()
+ : "unknown";
+
+ logger.info("Processing index: {} (status: {})", indexName, status);
+
+ // Skip indexes that are already terminating
+ if ("Terminating".equalsIgnoreCase(status)) {
+ logger.info("Skipping index {} - already terminating", indexName);
+ return false;
+ }
+
+ // Note: Age-based filtering would go here when timestamp information becomes available
+ // For now, we process all indexes that aren't already terminating
+
+ if (dryRun) {
+ logger.info("DRY-RUN: Would delete index: {}", indexName);
+ return false;
+ }
+
+ // Handle deletion protection
+ if ("enabled".equals(index.getDeletionProtection())) {
+ logger.info("Index {} has deletion protection enabled, disabling...", indexName);
+ try {
+ // Try pod-based configuration first
+ index.getSpec().getIndexModelPodBased();
+ pinecone.configurePodsIndex(indexName, "disabled");
+ } catch (ClassCastException e) {
+ // Not a pod-based index, try serverless
+ pinecone.configureServerlessIndex(indexName, "disabled", null, null);
+ }
+
+ // Wait for configuration to take effect
+ logger.info("Waiting 5 seconds for deletion protection to be disabled...");
+ Thread.sleep(5000);
+ }
+
+ // Delete the index
+ logger.info("Deleting index: {}", indexName);
+ pinecone.deleteIndex(indexName);
+ logger.info("Successfully initiated deletion of index: {}", indexName);
+
+ // Add small delay to avoid rate limiting
+ Thread.sleep(1000);
+ return true;
+ }
+
+ /**
+ * Cleans up a single collection.
+ *
+ * @param collection The collection to clean up
+ * @throws Exception if an error occurs during cleanup
+ */
+ private boolean cleanupCollection(CollectionModel collection) throws Exception {
+ String collectionName = collection.getName();
+ String status = collection.getStatus() != null ? collection.getStatus() : "unknown";
+
+ logger.info("Processing collection: {} (status: {})", collectionName, status);
+
+ // Skip collections that are already terminating
+ if ("Terminating".equalsIgnoreCase(status)) {
+ logger.info("Skipping collection {} - already terminating", collectionName);
+ return false;
+ }
+
+ // Note: Age-based filtering would go here when timestamp information becomes available
+ // For now, we process all collections that aren't already terminating
+
+ if (dryRun) {
+ logger.info("DRY-RUN: Would delete collection: {}", collectionName);
+ return false;
+ }
+
+ // Delete the collection
+ logger.info("Deleting collection: {}", collectionName);
+ pinecone.deleteCollection(collectionName);
+ logger.info("Successfully initiated deletion of collection: {}", collectionName);
+
+ // Add small delay to avoid rate limiting
+ Thread.sleep(1000);
+ return true;
+ }
+
+ /**
+ * Result of a cleanup operation, containing statistics about what was processed and deleted.
+ */
+ public static class CleanupResult {
+ private int indexesProcessed = 0;
+ private int indexesDeleted = 0;
+ private int indexesFailed = 0;
+ private int collectionsProcessed = 0;
+ private int collectionsDeleted = 0;
+ private int collectionsFailed = 0;
+
+ public int getIndexesProcessed() { return indexesProcessed; }
+ public int getIndexesDeleted() { return indexesDeleted; }
+ public int getIndexesFailed() { return indexesFailed; }
+ public int getCollectionsProcessed() { return collectionsProcessed; }
+ public int getCollectionsDeleted() { return collectionsDeleted; }
+ public int getCollectionsFailed() { return collectionsFailed; }
+
+ void incrementIndexesProcessed() { indexesProcessed++; }
+ void incrementIndexesDeleted() { indexesDeleted++; }
+ void incrementIndexesFailed() { indexesFailed++; }
+ void incrementCollectionsProcessed() { collectionsProcessed++; }
+ void incrementCollectionsDeleted() { collectionsDeleted++; }
+ void incrementCollectionsFailed() { collectionsFailed++; }
+ }
}
diff --git a/src/test/java/io/pinecone/helpers/IndexCleanupUtilityTest.java b/src/test/java/io/pinecone/helpers/IndexCleanupUtilityTest.java
new file mode 100644
index 00000000..df4faee3
--- /dev/null
+++ b/src/test/java/io/pinecone/helpers/IndexCleanupUtilityTest.java
@@ -0,0 +1,159 @@
+package io.pinecone.helpers;
+
+import io.pinecone.clients.Pinecone;
+import org.junit.jupiter.api.Test;
+import org.openapitools.db_control.client.model.CollectionList;
+import org.openapitools.db_control.client.model.CollectionModel;
+import org.openapitools.db_control.client.model.IndexList;
+import org.openapitools.db_control.client.model.IndexModel;
+import org.openapitools.db_control.client.model.IndexModelStatus;
+
+import java.util.Arrays;
+import java.util.Collections;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+class IndexCleanupUtilityTest {
+
+ @Test
+ void cleanup_nullIndexesList_doesNotThrowAndProcessesZero() throws Exception {
+ Pinecone pinecone = mock(Pinecone.class);
+
+ when(pinecone.listIndexes()).thenReturn(new IndexList());
+ when(pinecone.listCollections()).thenReturn(new CollectionList().collections(Collections.emptyList()));
+
+ IndexCleanupUtility utility = new IndexCleanupUtility(pinecone, 1, false);
+ IndexCleanupUtility.CleanupResult result = utility.cleanup();
+
+ assertEquals(0, result.getIndexesProcessed());
+ assertEquals(0, result.getIndexesDeleted());
+ assertEquals(0, result.getIndexesFailed());
+
+ assertEquals(0, result.getCollectionsProcessed());
+ assertEquals(0, result.getCollectionsDeleted());
+ assertEquals(0, result.getCollectionsFailed());
+
+ verify(pinecone, never()).deleteIndex(anyString());
+ verify(pinecone, never()).deleteCollection(anyString());
+ }
+
+ @Test
+ void cleanup_nullCollectionsListObject_doesNotThrowAndProcessesZero() throws Exception {
+ Pinecone pinecone = mock(Pinecone.class);
+
+ when(pinecone.listIndexes()).thenReturn(new IndexList().indexes(Collections.emptyList()));
+ when(pinecone.listCollections()).thenReturn(null);
+
+ IndexCleanupUtility utility = new IndexCleanupUtility(pinecone, 1, false);
+ IndexCleanupUtility.CleanupResult result = utility.cleanup();
+
+ assertEquals(0, result.getIndexesProcessed());
+ assertEquals(0, result.getIndexesDeleted());
+ assertEquals(0, result.getIndexesFailed());
+
+ assertEquals(0, result.getCollectionsProcessed());
+ assertEquals(0, result.getCollectionsDeleted());
+ assertEquals(0, result.getCollectionsFailed());
+
+ verify(pinecone, never()).deleteIndex(anyString());
+ verify(pinecone, never()).deleteCollection(anyString());
+ }
+
+ @Test
+ void cleanup_dryRun_doesNotIncrementDeletedCounts() throws Exception {
+ Pinecone pinecone = mock(Pinecone.class);
+
+ IndexList indexes = new IndexList().indexes(Arrays.asList(
+ new IndexModel().name("ready-index").status(new IndexModelStatus().state("Ready").ready(true)),
+ new IndexModel().name("terminating-index").status(new IndexModelStatus().state("Terminating").ready(false))
+ ));
+ when(pinecone.listIndexes()).thenReturn(indexes);
+
+ CollectionList collections = new CollectionList().collections(Arrays.asList(
+ new CollectionModel().name("ready-collection").status("Ready"),
+ new CollectionModel().name("terminating-collection").status("Terminating")
+ ));
+ when(pinecone.listCollections()).thenReturn(collections);
+
+ IndexCleanupUtility utility = new IndexCleanupUtility(pinecone, 1, true);
+ IndexCleanupUtility.CleanupResult result = utility.cleanup();
+
+ assertEquals(2, result.getIndexesProcessed());
+ assertEquals(0, result.getIndexesDeleted());
+ assertEquals(0, result.getIndexesFailed());
+
+ assertEquals(2, result.getCollectionsProcessed());
+ assertEquals(0, result.getCollectionsDeleted());
+ assertEquals(0, result.getCollectionsFailed());
+
+ verify(pinecone, never()).deleteIndex(anyString());
+ verify(pinecone, never()).deleteCollection(anyString());
+ }
+
+ @Test
+ void cleanup_terminatingResources_doesNotIncrementDeletedCounts() throws Exception {
+ Pinecone pinecone = mock(Pinecone.class);
+
+ IndexList indexes = new IndexList().indexes(Collections.singletonList(
+ new IndexModel().name("terminating-index").status(new IndexModelStatus().state("Terminating").ready(false))
+ ));
+ when(pinecone.listIndexes()).thenReturn(indexes);
+
+ CollectionList collections = new CollectionList().collections(Collections.singletonList(
+ new CollectionModel().name("terminating-collection").status("Terminating")
+ ));
+ when(pinecone.listCollections()).thenReturn(collections);
+
+ IndexCleanupUtility utility = new IndexCleanupUtility(pinecone, 1, false);
+ IndexCleanupUtility.CleanupResult result = utility.cleanup();
+
+ assertEquals(1, result.getIndexesProcessed());
+ assertEquals(0, result.getIndexesDeleted());
+ assertEquals(0, result.getIndexesFailed());
+
+ assertEquals(1, result.getCollectionsProcessed());
+ assertEquals(0, result.getCollectionsDeleted());
+ assertEquals(0, result.getCollectionsFailed());
+
+ verify(pinecone, never()).deleteIndex(anyString());
+ verify(pinecone, never()).deleteCollection(anyString());
+ }
+
+ @Test
+ void cleanup_nonDryRun_incrementsDeletedCountsWhenDeletionIsInitiated() throws Exception {
+ Pinecone pinecone = mock(Pinecone.class);
+
+ IndexList indexes = new IndexList().indexes(Collections.singletonList(
+ new IndexModel()
+ .name("ready-index")
+ .deletionProtection("disabled")
+ .status(new IndexModelStatus().state("Ready").ready(true))
+ ));
+ when(pinecone.listIndexes()).thenReturn(indexes);
+
+ CollectionList collections = new CollectionList().collections(Collections.singletonList(
+ new CollectionModel().name("ready-collection").status("Ready")
+ ));
+ when(pinecone.listCollections()).thenReturn(collections);
+
+ IndexCleanupUtility utility = new IndexCleanupUtility(pinecone, 1, false);
+ IndexCleanupUtility.CleanupResult result = utility.cleanup();
+
+ assertEquals(1, result.getIndexesProcessed());
+ assertEquals(1, result.getIndexesDeleted());
+ assertEquals(0, result.getIndexesFailed());
+
+ assertEquals(1, result.getCollectionsProcessed());
+ assertEquals(1, result.getCollectionsDeleted());
+ assertEquals(0, result.getCollectionsFailed());
+
+ verify(pinecone).deleteIndex("ready-index");
+ verify(pinecone).deleteCollection("ready-collection");
+ }
+}
+