From fdc129bc8f6b2805a633ce747f39327785be1a5c Mon Sep 17 00:00:00 2001 From: jonra82 Date: Mon, 3 Aug 2026 15:34:44 +0200 Subject: [PATCH] feat(clusters): add force param to cluster purge endpoint DELETE /v1/clusters/uid/{uid}?force=true skips the 10-minute recent-activity guard. Intended for controlled decommissioning where the caller has already verified the cluster's agents are stopped: the guard also counts heartbeats from external reporters (management plane), which keep refreshing lastobserved while a cluster is being torn down and can delay a legitimate purge by 10-30 minutes. Access is unchanged: the endpoint still requires ror global delete. A forced purge that skips the guard logs a warning with both report timestamps for auditability. Co-Authored-By: Claude Fable 5 --- .../clustersservice/clusters_purge.go | 25 ++++++++++++++----- .../clusters_controller_delete.go | 10 ++++++-- 2 files changed, 27 insertions(+), 8 deletions(-) diff --git a/internal/apiservices/clustersservice/clusters_purge.go b/internal/apiservices/clustersservice/clusters_purge.go index f39e3ff..436abc9 100644 --- a/internal/apiservices/clustersservice/clusters_purge.go +++ b/internal/apiservices/clustersservice/clusters_purge.go @@ -53,7 +53,12 @@ type PurgeResult struct { // // The clusterid required for the v1 resources collection is resolved from the // cluster document. ErrClusterNotFound is returned if no cluster matches uid. -func PurgeClusterByUid(ctx context.Context, uid string) (PurgeResult, error) { +// +// force skips the recent-activity safety check. Intended for controlled +// decommissioning where the caller has already verified the cluster's agents +// are stopped (e.g. pre-cluster-delete tooling); external reporters may keep +// refreshing lastobserved long after the cluster itself is being torn down. +func PurgeClusterByUid(ctx context.Context, uid string, force bool) (PurgeResult, error) { ctx, span := rortracer.StartSpan(ctx, "clustersservice.PurgeClusterByUid") defer span.End() @@ -95,11 +100,19 @@ func PurgeClusterByUid(ctx context.Context, uid string) (PurgeResult, error) { lastReport = lastSeenV2 } if !lastReport.IsZero() && time.Since(lastReport) < clusterInactivityThreshold { - return result, fmt.Errorf("%w: last reported %s ago (v1: %s, v2: %s)", - ErrClusterRecentlyActive, - time.Since(lastReport).Round(time.Second), - formatReportTime(clusterDoc.LastObserved), - formatReportTime(lastSeenV2), + if !force { + return result, fmt.Errorf("%w: last reported %s ago (v1: %s, v2: %s)", + ErrClusterRecentlyActive, + time.Since(lastReport).Round(time.Second), + formatReportTime(clusterDoc.LastObserved), + formatReportTime(lastSeenV2), + ) + } + rlog.Warnc(ctx, "force purge: skipping recent-activity check", + rlog.String("uid", uid), + rlog.String("clusterid", clusterDoc.ClusterId), + rlog.String("last report v1", formatReportTime(clusterDoc.LastObserved)), + rlog.String("last report v2", formatReportTime(lastSeenV2)), ) } diff --git a/internal/controllers/clusterscontroller/clusters_controller_delete.go b/internal/controllers/clusterscontroller/clusters_controller_delete.go index 9cb330e..8ad3942 100644 --- a/internal/controllers/clusterscontroller/clusters_controller_delete.go +++ b/internal/controllers/clusterscontroller/clusters_controller_delete.go @@ -3,6 +3,7 @@ package clusterscontroller import ( "errors" "net/http" + "strconv" "github.com/NorskHelsenett/ror-api/internal/acl/aclservice" "github.com/NorskHelsenett/ror-api/internal/apiservices/clustersservice" @@ -26,7 +27,8 @@ import ( // @Tags clusters // @Accept application/json // @Produce application/json -// @Param uid path string true "cluster uid" +// @Param uid path string true "cluster uid" +// @Param force query bool false "skip the recent-activity safety check; for controlled decommissioning where the caller has verified the cluster's agents are stopped" // @Success 200 {object} clustersservice.PurgeResult // @Failure 403 {string} Forbidden // @Failure 401 {object} rorerror.ErrorData @@ -58,7 +60,11 @@ func DeleteClusterByUid() gin.HandlerFunc { return } - result, err := clustersservice.PurgeClusterByUid(ctx, uid) + // force=true skips the recent-activity guard — restricted to callers that + // already hold ror global delete access (checked above). + force, _ := strconv.ParseBool(c.Query("force")) + + result, err := clustersservice.PurgeClusterByUid(ctx, uid, force) if err != nil { if errors.Is(err, clustersservice.ErrClusterNotFound) { c.JSON(http.StatusNotFound, "404: Cluster not found")