Conversation
WalkthroughAdds API deletion events/handling and broadcasts, renames deployment identifier RevisionID → DeploymentID across gateway and platform, injects an apiKeyXDSManager into the control-plane client, and implements deletion/cleanup pipelines (DB, in-memory store, policy, xDS) with comprehensive tests. Changes
Sequence Diagram(s)sequenceDiagram
participant ControlPlane as Control Plane
participant Client as Control Plane Client
participant DB as Database
participant Store as Config Store
participant Policy as Policy Engine
participant XDS as XDS Manager
ControlPlane->>Client: WebSocket "api.deleted" (apiId, environment, vhost)
Client->>DB: findAPIConfig(apiId, environment)
DB-->>Client: config or not found
alt API found
Client->>DB: performFullAPIDeletion(apiId)
Client->>Store: remove in-memory config & purge API keys
Client->>Policy: remove derived policies
Client->>XDS: updateXDSSnapshotAsync()
else Orphan / not found
Client->>Store: cleanupOrphanedResources(apiId)
Client->>XDS: conditional snapshot update
end
Client-->>ControlPlane: log/report deletion outcome
sequenceDiagram
participant APIService as Platform API Service
participant GatewayEvents as GatewayEventsService
participant GatewayStore as Gateway connection store
APIService->>GatewayEvents: BroadcastAPIDeletionEvent(gatewayID, APIDeletionEvent)
GatewayEvents->>GatewayEvents: Generate correlationId, serialize payload
GatewayEvents->>GatewayStore: Get active connections for gatewayID
GatewayStore-->>GatewayEvents: Return connections
loop per connection
GatewayEvents->>Connection: Send event (type: "api.deleted")
Connection-->>GatewayEvents: Ack / Error
end
GatewayEvents-->>APIService: Aggregated delivery result
Estimated code review effort🎯 4 (Complex) | ⏱️ ~45 minutes Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing touches
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@gateway/gateway-controller/pkg/controlplane/client.go`:
- Around line 876-899: The cleanup currently treats any error from
c.db.RemoveAPIKeysAPI and c.store.RemoveAPIKeysByAPI as "no API keys found" and
logs at Debug; update the error handling in the cleanup block so you distinguish
a not-found result from real errors (inspect the returned error or use the
package's sentinel/generic IsNotFound helper) and only log the not-found case as
Info/Debug, while logging genuine failures at Warn or Error and include the
error details (use c.logger.Warn/c.logger.Error with slog.String("api_id",
apiID) and slog.Any("err", err)); keep setting hasOrphanedResources=true only
when removal succeeded and ensure both c.db.RemoveAPIKeysAPI and
c.store.RemoveAPIKeysByAPI follow this corrected pattern.
🧹 Nitpick comments (4)
gateway/gateway-controller/pkg/api/handlers/handlers_test.go (1)
397-398: Reminder: rebuild gateway images after gateway changes.
Please runcd gateway && make build-localas part of this update. As per coding guidelines: “gateway/**/*.{go,yaml,yml,Dockerfile}: When modifying code in gateway components … rebuild Docker images usingcd gateway && make build-local.”platform-api/src/internal/service/api.go (1)
340-345: Hardcoded"production"environment — consistent but potentially fragile.The environment is hardcoded to
"production"here, matching the same pattern indeployment.go(lines 194, 262, 325). If multi-environment support is planned, this should eventually be derived from the deployment or gateway configuration. Not a blocker since it's consistent with existing code.platform-api/src/internal/service/gateway_events.go (1)
211-282: Consider extracting a generic broadcast helper to reduce duplication.All
Broadcast*Eventmethods in this file share ~95% identical logic (correlationID generation, payload serialization, size validation, DTO creation, connection iteration, stats tracking, summary logging). A generic helper parameterized by event type string and payload would eliminate this repeated boilerplate. Not urgent given the existing pattern, but the surface area keeps growing with each new event type.gateway/gateway-controller/pkg/controlplane/client.go (1)
804-1083: Consider extracting orphan cleanup and full cleanup into private helpers.
handleAPIDeletedEventis ~280 lines with two distinct code paths (orphan cleanup vs. full cleanup). Extracting these intocleanupOrphanedResources(apiID, correlationID)andperformFullAPIDeletion(apiID, apiConfig, correlationID)helpers would improve readability and testability.
There was a problem hiding this comment.
Pull request overview
Adds an explicit api.deleted gateway event so gateways can proactively clean up API config/routes/keys when an API is permanently removed from the platform. This also standardizes deployment event payload naming by switching from revisionId to deploymentId across platform-api and gateway-controller.
Changes:
- Add
api.deletedevent model + broadcaster in platform-api and emit it during API deletion. - Rename deployment event field
revisionId→deploymentIdacross DTOs/models and gateway-controller consumers/tests. - Add gateway-controller handling for
api.deletedto clean up stored configs, API keys, policy artifacts, and update snapshots.
Reviewed changes
Copilot reviewed 11 out of 11 changed files in this pull request and generated 4 comments.
Show a summary per file
| File | Description |
|---|---|
| platform-api/src/internal/service/gateway_events.go | Adds BroadcastAPIDeletionEvent to publish api.deleted over WebSocket. |
| platform-api/src/internal/service/deployment.go | Updates deployment event payload to use DeploymentID instead of RevisionID. |
| platform-api/src/internal/service/api.go | Fetches gateway associations pre-delete and broadcasts api.deleted to associated gateways. |
| platform-api/src/internal/model/gateway_event.go | Extends gateway event model: adds APIDeletionEvent and renames RevisionID → DeploymentID. |
| platform-api/src/internal/dto/gateway_event.go | Renames DTO field RevisionID → DeploymentID. |
| gateway/gateway-controller/pkg/utils/api_utils_test.go | Comment-only update reflecting deployment terminology. |
| gateway/gateway-controller/pkg/controlplane/events.go | Adds APIDeletedEvent structs and renames deployed payload field to DeploymentID. |
| gateway/gateway-controller/pkg/controlplane/controlplane_test.go | Updates tests to validate DeploymentID field. |
| gateway/gateway-controller/pkg/controlplane/client_integration_test.go | Updates deployed event JSON + adds api.deleted to “all event types” coverage. |
| gateway/gateway-controller/pkg/controlplane/client.go | Handles api.deleted and performs cleanup + snapshot updates; adds apiKeyXDSManager to support policy-engine API key cleanup. |
| gateway/gateway-controller/pkg/api/handlers/handlers_test.go | Updates mock signature parameter name (revisionID → deploymentID). |
| // Get all gateway associations BEFORE deletion (associations will be cascade deleted) | ||
| gatewayAssociations, err := s.apiRepo.GetAPIAssociations(apiUUID, constants.AssociationTypeGateway, orgUUID) | ||
| if err != nil { | ||
| log.Printf("[WARN] Failed to get gateway associations for API deletion: apiUUID=%s error=%v", apiUUID, err) | ||
| } |
There was a problem hiding this comment.
If GetAPIAssociations fails here, the API is still deleted but no gateway deletion events will be sent (and associations are cascade-deleted, so you can’t recover the list later). That can leave gateways serving stale routes/config for a deleted API. Consider treating this as a hard failure (return the error) or otherwise ensuring you can still notify all relevant gateways before proceeding with the repository delete.
There was a problem hiding this comment.
Actionable comments posted: 3
🤖 Fix all issues with AI agents
In `@gateway/gateway-controller/pkg/controlplane/client.go`:
- Around line 889-918: The non-orphaned log messages in updateXDSSnapshotAsync
incorrectly say "API deletion" when the call can come from undeployment (via
handleAPIUndeployedEvent), so change the logging to reflect the actual
operation: add a small parameter (e.g., op string or isUndeploy bool) to
updateXDSSnapshotAsync or compute an operation label before logging and use a
generic message like "after API change" or the op label; update the error/info
branches to use that label (referencing updateXDSSnapshotAsync and the caller
handleAPIUndeployedEvent) so logs are accurate for both deletion and
undeployment contexts.
- Around line 816-831: findAPIConfig currently treats any db.GetConfig error as
"not found", which hides transient DB failures and causes handleAPIDeletedEvent
to take the orphan path; change findAPIConfig (and its callers) to surface DB
errors instead of folding them into a not-found result by updating the signature
to return (*models.StoredConfig, bool, error) or (*models.StoredConfig, error)
with a distinct not-found sentinel, have findAPIConfig return the actual db
error when c.db.GetConfig returns an error, and update handleAPIDeletedEvent to
check for and handle that error (retry/abort) before performing orphan cleanup
so real DB failures are not treated as missing configs.
- Around line 1077-1081: The current sequence calls
c.updateXDSSnapshotAsync(apiID, correlationID, false) before
c.removePolicyConfiguration(apiID, correlationID, false), which can let the
async snapshot include deleted policy; swap the two calls so
c.removePolicyConfiguration(apiID, correlationID, false) is invoked first, then
c.updateXDSSnapshotAsync(apiID, correlationID, false), ensuring policy removal
happens before the xDS snapshot computation (use the same apiID and
correlationID parameters).
| // updateXDSSnapshotAsync updates xDS snapshot in the background | ||
| func (c *Client) updateXDSSnapshotAsync(apiID, correlationID string, isOrphaned bool) { | ||
| if c.snapshotManager == nil { | ||
| return | ||
| } | ||
|
|
||
| go func() { | ||
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| if err := c.snapshotManager.UpdateSnapshot(ctx, undeployedEvent.CorrelationID); err != nil { | ||
| c.logger.Error("Failed to update xDS snapshot after API undeployment", | ||
| if err := c.snapshotManager.UpdateSnapshot(ctx, correlationID); err != nil { | ||
| // Log level depends on operation context | ||
| if isOrphaned { | ||
| c.logger.Warn("Failed to update xDS snapshot for orphaned resource cleanup", | ||
| slog.String("api_id", apiID), | ||
| slog.Any("error", err), | ||
| ) | ||
| } else { | ||
| c.logger.Error("Failed to update xDS snapshot after API deletion", | ||
| slog.String("api_id", apiID), | ||
| slog.Any("error", err), | ||
| ) | ||
| } | ||
| } else if !isOrphaned { | ||
| c.logger.Info("Successfully updated xDS snapshot after API deletion", | ||
| slog.String("api_id", apiID), | ||
| ) | ||
| } | ||
| }() | ||
| } |
There was a problem hiding this comment.
Log messages are misleading when called from the undeployment path.
updateXDSSnapshotAsync is called from handleAPIUndeployedEvent (line 808) with isOrphaned=false, but the non-orphaned log messages at lines 907 and 913 reference "API deletion." This will produce confusing log entries during undeployment events.
Consider parameterizing the operation context or using a more generic message:
Suggested fix
- } else {
- c.logger.Error("Failed to update xDS snapshot after API deletion",
- slog.String("api_id", apiID),
- slog.Any("error", err),
- )
- }
- } else if !isOrphaned {
- c.logger.Info("Successfully updated xDS snapshot after API deletion",
- slog.String("api_id", apiID),
- )
+ } else {
+ c.logger.Error("Failed to update xDS snapshot",
+ slog.String("api_id", apiID),
+ slog.String("correlation_id", correlationID),
+ slog.Any("error", err),
+ )
+ }
+ } else if !isOrphaned {
+ c.logger.Info("Successfully updated xDS snapshot",
+ slog.String("api_id", apiID),
+ slog.String("correlation_id", correlationID),
+ )📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| // updateXDSSnapshotAsync updates xDS snapshot in the background | |
| func (c *Client) updateXDSSnapshotAsync(apiID, correlationID string, isOrphaned bool) { | |
| if c.snapshotManager == nil { | |
| return | |
| } | |
| go func() { | |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| defer cancel() | |
| if err := c.snapshotManager.UpdateSnapshot(ctx, undeployedEvent.CorrelationID); err != nil { | |
| c.logger.Error("Failed to update xDS snapshot after API undeployment", | |
| if err := c.snapshotManager.UpdateSnapshot(ctx, correlationID); err != nil { | |
| // Log level depends on operation context | |
| if isOrphaned { | |
| c.logger.Warn("Failed to update xDS snapshot for orphaned resource cleanup", | |
| slog.String("api_id", apiID), | |
| slog.Any("error", err), | |
| ) | |
| } else { | |
| c.logger.Error("Failed to update xDS snapshot after API deletion", | |
| slog.String("api_id", apiID), | |
| slog.Any("error", err), | |
| ) | |
| } | |
| } else if !isOrphaned { | |
| c.logger.Info("Successfully updated xDS snapshot after API deletion", | |
| slog.String("api_id", apiID), | |
| ) | |
| } | |
| }() | |
| } | |
| // updateXDSSnapshotAsync updates xDS snapshot in the background | |
| func (c *Client) updateXDSSnapshotAsync(apiID, correlationID string, isOrphaned bool) { | |
| if c.snapshotManager == nil { | |
| return | |
| } | |
| go func() { | |
| ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) | |
| defer cancel() | |
| if err := c.snapshotManager.UpdateSnapshot(ctx, correlationID); err != nil { | |
| // Log level depends on operation context | |
| if isOrphaned { | |
| c.logger.Warn("Failed to update xDS snapshot for orphaned resource cleanup", | |
| slog.String("api_id", apiID), | |
| slog.Any("error", err), | |
| ) | |
| } else { | |
| c.logger.Error("Failed to update xDS snapshot", | |
| slog.String("api_id", apiID), | |
| slog.String("correlation_id", correlationID), | |
| slog.Any("error", err), | |
| ) | |
| } | |
| } else if !isOrphaned { | |
| c.logger.Info("Successfully updated xDS snapshot", | |
| slog.String("api_id", apiID), | |
| slog.String("correlation_id", correlationID), | |
| ) | |
| } | |
| }() | |
| } |
🤖 Prompt for AI Agents
In `@gateway/gateway-controller/pkg/controlplane/client.go` around lines 889 -
918, The non-orphaned log messages in updateXDSSnapshotAsync incorrectly say
"API deletion" when the call can come from undeployment (via
handleAPIUndeployedEvent), so change the logging to reflect the actual
operation: add a small parameter (e.g., op string or isUndeploy bool) to
updateXDSSnapshotAsync or compute an operation label before logging and use a
generic message like "after API change" or the op label; update the error/info
branches to use that label (referencing updateXDSSnapshotAsync and the caller
handleAPIUndeployedEvent) so logs are accurate for both deletion and
undeployment contexts.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@gateway/gateway-controller/pkg/controlplane/api_deleted_test.go`:
- Around line 243-247: The test is not exercising policy cleanup because the
test Client is missing policyManager and apiKeyXDSManager and
StoredConfig.Configuration is incomplete; update the test's Client
initialization to include a mock/fake policyManager and apiKeyXDSManager, ensure
the test calls handleAPIDeletedEvent (or the same deletion path) so
removePolicyConfiguration is invoked on the policyManager, and add assertions
that the policyManager's RemovePolicy/RemoveAPIKey (or equivalent methods) were
called; also update createTestAPIConfigForDeletion to populate
StoredConfig.Configuration with a valid API configuration so the deletion flow
can identify keys/policies to remove.
🧹 Nitpick comments (3)
gateway/gateway-controller/pkg/controlplane/api_deleted_test.go (3)
341-385: Test does not verify policy engine cleanup — the key differentiator of delete vs. undeploy.Per the project's design, deletion is a "hard cleanup" that should remove policies from the policy engine, unlike undeployment which preserves them. This test verifies DB and memory cleanup but doesn't assert that policies/xDS resources were cleaned up. If the production
handleAPIDeletedEventcalls a policy engine or xDS manager, the test should mock and verify those interactions.Based on learnings: "Only DELETE operations should remove policies from the policy engine. Undeployment is a soft removal that maintains state, while deletion is a hard cleanup."
454-465: Missing assertion: verify memory store cleanup proceeds despite DB errors.The test confirms that DB operations were attempted, but doesn't check whether the in-memory store was still cleaned up after DB failures. Since deletion should be best-effort across all layers, verifying that
store.Get(apiID)returns an error (config removed) would strengthen this test.💡 Suggested additional assertion
if db.removeKeyCallCount != 1 { t.Errorf("Expected RemoveAPIKeysAPI to be attempted, got %d", db.removeKeyCallCount) } + + // Verify memory cleanup still occurs despite DB errors + _, err := store.Get(apiID) + if err == nil { + t.Error("Expected API config to be removed from memory store despite DB errors") + } }
33-227: Consider using mock generation or interface embedding to reduce boilerplate.The mock spans ~200 lines, most of which are no-op stubs required to satisfy the
storage.Storageinterface. Embedding a base no-op implementation or using a tool likemockgen/moqwould significantly reduce this boilerplate and make the test file easier to maintain as the interface evolves.
| client := &Client{ | ||
| logger: logger, | ||
| store: store, | ||
| db: db, | ||
| } |
There was a problem hiding this comment.
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Find the Client struct definition in the production code
echo "=== Client struct definition ==="
rg "type Client struct" gateway/gateway-controller/pkg/controlplane/ -A 20
echo ""
echo "=== handleAPIDeletedEvent method signature and start ==="
rg "func \(c \*Client\) handleAPIDeletedEvent" gateway/gateway-controller/pkg/controlplane/ -A 30
echo ""
echo "=== findAPIConfig method ==="
rg "func \(c \*Client\) findAPIConfig" gateway/gateway-controller/pkg/controlplane/ -A 20Repository: wso2/api-platform
Length of output: 6379
🏁 Script executed:
#!/bin/bash
# Get the full handleAPIDeletedEvent method
echo "=== Full handleAPIDeletedEvent method ==="
sed -n '688,795p' gateway/gateway-controller/pkg/controlplane/client.go
echo ""
echo "=== Check if removePolicyConfiguration is called in handleAPIDeletedEvent ==="
rg "removePolicyConfiguration" gateway/gateway-controller/pkg/controlplane/client.goRepository: wso2/api-platform
Length of output: 3666
🏁 Script executed:
#!/bin/bash
# Search specifically for handleAPIDeletedEvent method
echo "=== handleAPIDeletedEvent method ==="
rg "func \(c \*Client\) handleAPIDeletedEvent" -A 100 gateway/gateway-controller/pkg/controlplane/client.go | head -120Repository: wso2/api-platform
Length of output: 3196
🏁 Script executed:
#!/bin/bash
# Get the cleanupOrphanedResources and performFullAPIDeletion methods
echo "=== cleanupOrphanedResources method ==="
rg "func \(c \*Client\) cleanupOrphanedResources" -A 50 gateway/gateway-controller/pkg/controlplane/client.go
echo ""
echo "=== performFullAPIDeletion method ==="
rg "func \(c \*Client\) performFullAPIDeletion" -A 60 gateway/gateway-controller/pkg/controlplane/client.goRepository: wso2/api-platform
Length of output: 4016
🏁 Script executed:
#!/bin/bash
# Get more of performFullAPIDeletion to see removePolicyConfiguration call
echo "=== Full performFullAPIDeletion method ==="
rg "func \(c \*Client\) performFullAPIDeletion" -A 100 gateway/gateway-controller/pkg/controlplane/client.go | head -120
echo ""
echo "=== Check StoredConfig struct to see Configuration field ==="
rg "type StoredConfig struct" -A 20 gateway/gateway-controller/pkg/models/Repository: wso2/api-platform
Length of output: 5657
🏁 Script executed:
#!/bin/bash
# Check the test file to see what createTestAPIConfigForDeletion creates and how it's used
echo "=== createTestAPIConfigForDeletion helper in test ==="
rg "func createTestAPIConfigForDeletion" -A 15 gateway/gateway-controller/pkg/controlplane/api_deleted_test.go
echo ""
echo "=== Check how tests use apiConfig ==="
sed -n '341,385p' gateway/gateway-controller/pkg/controlplane/api_deleted_test.goRepository: wso2/api-platform
Length of output: 1960
Tests don't verify policy cleanup during API deletion.
The test initializes Client with only logger, store, and db, but the handleAPIDeletedEvent flow requires policyManager to complete the deletion. The production code calls removePolicyConfiguration as the final cleanup step, but since policyManager is nil in tests, this step is skipped. Per the deletion design, policies should be removed from the policy engine during DELETE operations (unlike undeployment, which preserves them). Add policyManager to the test Client initialization and add assertions verifying policy cleanup is attempted.
Additionally, the test helper createTestAPIConfigForDeletion creates a StoredConfig with only ID and Status set. For complete coverage, populate the Configuration field with a valid API configuration, and initialize apiKeyXDSManager to test that API key removal from the policy engine is also verified.
🤖 Prompt for AI Agents
In `@gateway/gateway-controller/pkg/controlplane/api_deleted_test.go` around lines
243 - 247, The test is not exercising policy cleanup because the test Client is
missing policyManager and apiKeyXDSManager and StoredConfig.Configuration is
incomplete; update the test's Client initialization to include a mock/fake
policyManager and apiKeyXDSManager, ensure the test calls handleAPIDeletedEvent
(or the same deletion path) so removePolicyConfiguration is invoked on the
policyManager, and add assertions that the policyManager's
RemovePolicy/RemoveAPIKey (or equivalent methods) were called; also update
createTestAPIConfigForDeletion to populate StoredConfig.Configuration with a
valid API configuration so the deletion flow can identify keys/policies to
remove.
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Fix all issues with AI agents
In `@gateway/gateway-controller/pkg/controlplane/client.go`:
- Around line 966-977: RemoveAPIKeysByAPI currently returns nil when no keys
were removed so the caller always treats the operation as having removed
something; change RemoveAPIKeysByAPI to return (int, error) (or an equivalent
removedCount and error) and update this call site to set hasOrphanedResources =
true only when removedCount > 0; adjust all other callers of RemoveAPIKeysByAPI
to handle the new (removedCount, err) signature and preserve existing error
logging via c.store.RemoveAPIKeysByAPI (now capturing count) before deciding to
flip hasOrphanedResources.
🧹 Nitpick comments (1)
gateway/gateway-controller/pkg/controlplane/client.go (1)
1013-1026: Misleading "for atomicity" comment — the operation is best-effort, not atomic.The comment at line 1013 says "for atomicity," but the code continues cleanup even when DB deletion fails (line 1020). Consider updating the comment to reflect the actual best-effort/cascading cleanup strategy.
| // Check and clean up orphaned API keys from memory store | ||
| if err := c.store.RemoveAPIKeysByAPI(apiID); err != nil { | ||
| c.logger.Warn("Failed to remove orphaned API keys from memory store", | ||
| slog.String("api_id", apiID), | ||
| slog.Any("error", err), | ||
| ) | ||
| } else { | ||
| hasOrphanedResources = true | ||
| c.logger.Debug("Checked and cleaned up orphaned API keys from memory store", | ||
| slog.String("api_id", apiID), | ||
| ) | ||
| } |
There was a problem hiding this comment.
hasOrphanedResources is always set to true even when no keys existed.
RemoveAPIKeysByAPI returns nil when there are no keys to remove (see memory.go line 694-696), so the else branch on line 972 always executes and sets hasOrphanedResources = true. This means the "No orphaned resources found" log at line 1000 is effectively dead code.
Consider checking the return value or count to determine if resources were actually cleaned up, or simplify the flag logic.
🤖 Prompt for AI Agents
In `@gateway/gateway-controller/pkg/controlplane/client.go` around lines 966 -
977, RemoveAPIKeysByAPI currently returns nil when no keys were removed so the
caller always treats the operation as having removed something; change
RemoveAPIKeysByAPI to return (int, error) (or an equivalent removedCount and
error) and update this call site to set hasOrphanedResources = true only when
removedCount > 0; adjust all other callers of RemoveAPIKeysByAPI to handle the
new (removedCount, err) signature and preserve existing error logging via
c.store.RemoveAPIKeysByAPI (now capturing count) before deciding to flip
hasOrphanedResources.
Purpose
Goals
Approach
User stories
Documentation
Automation tests
Security checks
Samples
Related PRs
Test environment
Summary by CodeRabbit
New Features
Improvements
Tests