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")