Skip to content

Commit 8856f9a

Browse files
committed
Bound backend calls and recover crash-stranded rows
Every Kubernetes REST config (Harvester, KubeOVN and its derived OpenBao/DBaaS/endpoints consumers, the admission webhook) now carries a 30-second request timeout, matching the Rancher client, so a hung backend releases its goroutine instead of holding it forever. The KubeOVN startup probe gets a deadline for the same reason. Each reconcile is bounded per resource so one stuck backend cannot stall every other PENDING/DELETING resource in the sequential loop. A create interrupted before the backend confirmed the resource left a PENDING row with no backend_uid: invisible to ListPending, stuck forever, its unique name blocked. The reconciler now sweeps such rows after 20 minutes — strictly above the largest provision budget (15 minutes for cluster creates, 10 for everything else). Stranded creates become FAILED with an explanatory message; stranded deletes are completed by removing the row, since nothing was ever created on the backend. Every reaped row emits a normal audit event. Handlers' fire-and-forget provisioning goroutines are now tracked by a small async.Group, and shutdown drains them for up to 60 seconds after the HTTP server stops, so a rolling deploy no longer strands creates mid-flight. The tracker tolerates tasks launched while the drain is in progress (a raw WaitGroup would panic if srv.Shutdown timed out with requests still active). Harvester GetVM and Rancher GetCluster now return a typed not-found error implementing the NotFound() sentinel the reconciler already looks for, replacing substring matching on those paths.
1 parent 8b8683f commit 8856f9a

24 files changed

Lines changed: 791 additions & 79 deletions

File tree

.github/workflows/authz.yaml

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,9 @@ name: authz-tests
1010
# real Harvester / KubeOVN cluster.
1111
#
1212
# Scope: the role × verb matrix, membership, service-account, tenant,
13-
# phase-6a slug-recycle, and project-capacity suites. Resource tests
14-
# (vnet/subnet/vm/cluster/...) genuinely provision on the cluster and are
15-
# deliberately NOT in scope here.
13+
# phase-6a slug-recycle, project-capacity, and orphan-reaper (pure-DB crash
14+
# recovery) suites. Resource tests (vnet/subnet/vm/cluster/...) genuinely
15+
# provision on the cluster and are deliberately NOT in scope here.
1616

1717
on:
1818
pull_request:
@@ -48,5 +48,5 @@ jobs:
4848
# single shared Postgres container + the ~35 status-code assertions.
4949
run: |
5050
go test -tags integration -timeout 12m -v \
51-
-run 'TestRBAC_|TestMembers_|TestServiceAccountAPI_|TestTenants_|TestPhase6a|TestProjectCap_' \
51+
-run 'TestRBAC_|TestMembers_|TestServiceAccountAPI_|TestTenants_|TestPhase6a|TestProjectCap_|TestReaper_' \
5252
./test/integration/...

dc-api/cmd/dc-api/main.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import (
2626
"github.com/wso2/dc-api/internal/api/auth"
2727
"github.com/wso2/dc-api/internal/api/handlers"
2828
"github.com/wso2/dc-api/internal/api/middleware"
29+
"github.com/wso2/dc-api/internal/async"
2930
"github.com/wso2/dc-api/internal/config"
3031
"github.com/wso2/dc-api/internal/db"
3132
"github.com/wso2/dc-api/internal/directory"
@@ -40,6 +41,14 @@ import (
4041
"github.com/wso2/dc-api/internal/reconciler"
4142
)
4243

44+
// provisionDrainTimeout bounds the post-Shutdown wait for the handlers'
45+
// fire-and-forget provisioning goroutines (async.Group). Long enough for the
46+
// typical backend submit to finish; deliberately shorter than a Kubernetes
47+
// terminationGracePeriod escalation would tolerate. Tasks still running when
48+
// it expires are abandoned and later reaped as FAILED by the reconciler's
49+
// orphan sweep.
50+
const provisionDrainTimeout = 60 * time.Second
51+
4352
func main() {
4453
// ── Configuration ─────────────────────────────────────────────────────────
4554
// config.Load() reads all DCAPI_* environment variables.
@@ -451,6 +460,12 @@ func main() {
451460
log.Info().Msg("IdP directory disabled (DCAPI_IDP_* unset) — invite by user_sub only")
452461
}
453462

463+
// ── Async provisioning task group ────────────────────────────────────────
464+
// Tracks the fire-and-forget provisioning goroutines the handlers launch
465+
// (VM/cluster/network creates + deletes) so shutdown can drain them below
466+
// instead of killing a provision mid-flight on a rolling deploy.
467+
tasks := &async.Group{}
468+
454469
// ── Router ────────────────────────────────────────────────────────────────
455470
// All wiring happens in NewRouter. main.go does not know about individual routes.
456471
router := api.NewRouter(api.RouterDeps{
@@ -492,6 +507,7 @@ func main() {
492507
LocalZone: cfg.LocalZone,
493508
AgentRouteReads: cfg.AgentRouteReads,
494509
AgentRouteWrites: cfg.AgentRouteWrites,
510+
Tasks: tasks,
495511
Log: log.Logger,
496512
})
497513

@@ -525,6 +541,18 @@ func main() {
525541
if err := srv.Shutdown(shutdownCtx); err != nil {
526542
log.Error().Err(err).Msg("graceful shutdown timed out")
527543
}
544+
545+
// Drain the detached provisioning goroutines (bounded). Without this, a
546+
// rolling deploy kills provisions mid-flight, stranding PENDING rows with
547+
// no backend_uid — the exact orphans the reconciler sweep exists to reap.
548+
log.Info().Dur("timeout", provisionDrainTimeout).Msg("waiting for in-flight provisioning tasks to finish")
549+
drainCtx, drainCancel := context.WithTimeout(context.Background(), provisionDrainTimeout)
550+
defer drainCancel()
551+
if tasks.Wait(drainCtx) {
552+
log.Info().Msg("in-flight provisioning tasks drained")
553+
} else {
554+
log.Warn().Msg("drain timed out — abandoning in-flight provisioning tasks; interrupted creates will be reaped as FAILED by the reconciler's orphan sweep")
555+
}
528556
log.Info().Msg("DC-API stopped cleanly")
529557
}
530558

dc-api/cmd/webhook/main.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,12 @@ type config struct {
6565
LogLevel string `envconfig:"LOG_LEVEL" default:"info"`
6666
}
6767

68+
// k8sRequestTimeout bounds every request made through the webhook's
69+
// rest.Config (both the in-cluster and kubeconfig paths) so a hung API server
70+
// can't block the NAD-lookup goroutines forever. Mirrors dc-api's provider
71+
// clients (30s, same as the Rancher http.Client timeout).
72+
const k8sRequestTimeout = 30 * time.Second
73+
6874
func main() {
6975
// ── Config ────────────────────────────────────────────────────────────────
7076
var cfg config
@@ -103,6 +109,9 @@ func main() {
103109
log.Fatal().Err(err).Msg("webhook: parse kubeconfig failed")
104110
}
105111
}
112+
// Applies to BOTH paths (in-cluster and kubeconfig) — set before the
113+
// dynamic client is built from this config.
114+
restCfg.Timeout = k8sRequestTimeout
106115

107116
dynClient, err := dynamic.NewForConfig(restCfg)
108117
if err != nil {

dc-api/internal/api/handlers/bastion.go

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
"github.com/google/uuid"
1414
"github.com/rs/zerolog"
1515
"github.com/wso2/dc-api/internal/api/middleware"
16+
"github.com/wso2/dc-api/internal/async"
1617
"github.com/wso2/dc-api/internal/db"
1718
"github.com/wso2/dc-api/internal/models"
1819
"github.com/wso2/dc-api/internal/placement"
@@ -43,7 +44,10 @@ type BastionHandler struct {
4344
bastionImage string // DCAPI_BASTION_IMAGE
4445
bastionMgmtNAD string // DCAPI_BASTION_MGMT_NAD
4546
dnsSearchDomain string // DCAPI_VPC_DNS_SEARCH_DOMAIN — same as VMHandler
46-
log zerolog.Logger
47+
// tasks tracks the async provisioning goroutines so shutdown can drain
48+
// them (bounded). May be nil (tests) — async.Group is nil-receiver-safe.
49+
tasks *async.Group
50+
log zerolog.Logger
4751
}
4852

4953
// NewBastionHandler creates a BastionHandler with injected dependencies.
@@ -52,6 +56,7 @@ func NewBastionHandler(
5256
resolve providers.Resolver,
5357
defaultRegion, defaultZone string,
5458
bastionImage, bastionMgmtNAD, dnsSearchDomain string,
59+
tasks *async.Group,
5560
log zerolog.Logger,
5661
) *BastionHandler {
5762
return &BastionHandler{
@@ -62,6 +67,7 @@ func NewBastionHandler(
6267
bastionImage: bastionImage,
6368
bastionMgmtNAD: bastionMgmtNAD,
6469
dnsSearchDomain: dnsSearchDomain,
70+
tasks: tasks,
6571
log: log,
6672
}
6773
}
@@ -318,7 +324,7 @@ func (h *BastionHandler) Create(w http.ResponseWriter, r *http.Request) {
318324
DNSSearchDomain: h.dnsSearchDomain,
319325
MgmtNAD: h.bastionMgmtNAD,
320326
}
321-
go h.asyncProvision(compute, resource.ID, tenantID, projectID, userID, spec, req.Description)
327+
h.tasks.Go(func() { h.asyncProvision(compute, resource.ID, tenantID, projectID, userID, spec, req.Description) })
322328

323329
w.Header().Set("Content-Type", "application/json")
324330
w.WriteHeader(http.StatusAccepted)
@@ -423,7 +429,7 @@ func (h *BastionHandler) Delete(w http.ResponseWriter, r *http.Request) {
423429
return
424430
}
425431

426-
go func() {
432+
h.tasks.Go(func() {
427433
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
428434
defer cancel()
429435
if resource.BackendUID == "" {
@@ -434,7 +440,7 @@ func (h *BastionHandler) Delete(w http.ResponseWriter, r *http.Request) {
434440
h.log.Error().Err(err).Str("backend_uid", resource.BackendUID).Msg("harvester delete bastion VM failed")
435441
_ = h.repo.UpdateStatus(ctx, id, models.StatusFailed, "deletion failed: "+err.Error(), "")
436442
}
437-
}()
443+
})
438444

439445
w.WriteHeader(http.StatusAccepted)
440446
}

dc-api/internal/api/handlers/cluster.go

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import (
3030
"github.com/google/uuid"
3131
"github.com/rs/zerolog"
3232
"github.com/wso2/dc-api/internal/api/middleware"
33+
"github.com/wso2/dc-api/internal/async"
3334
"github.com/wso2/dc-api/internal/db"
3435
"github.com/wso2/dc-api/internal/models"
3536
"github.com/wso2/dc-api/internal/placement"
@@ -49,12 +50,15 @@ type ClusterHandler struct {
4950
resolve providers.Resolver
5051
defaultRegion string
5152
defaultZone string
52-
log zerolog.Logger
53+
// tasks tracks the async provisioning goroutines so shutdown can drain
54+
// them (bounded). May be nil (tests) — async.Group is nil-receiver-safe.
55+
tasks *async.Group
56+
log zerolog.Logger
5357
}
5458

5559
// NewClusterHandler creates a ClusterHandler with injected dependencies.
56-
func NewClusterHandler(repo *db.Repository, resolve providers.Resolver, defaultRegion, defaultZone string, log zerolog.Logger) *ClusterHandler {
57-
return &ClusterHandler{repo: repo, resolve: resolve, defaultRegion: defaultRegion, defaultZone: defaultZone, log: log}
60+
func NewClusterHandler(repo *db.Repository, resolve providers.Resolver, defaultRegion, defaultZone string, tasks *async.Group, log zerolog.Logger) *ClusterHandler {
61+
return &ClusterHandler{repo: repo, resolve: resolve, defaultRegion: defaultRegion, defaultZone: defaultZone, tasks: tasks, log: log}
5862
}
5963

6064
// cluster resolves the ClusterProvider for a cluster's (region, zone). Empty
@@ -546,7 +550,7 @@ func (h *ClusterHandler) Create(w http.ResponseWriter, r *http.Request) {
546550
},
547551
WorkerPools: workerPools,
548552
}
549-
go h.asyncProvision(cluster, resource.ID, tenantID, projectID, userID, spec)
553+
h.tasks.Go(func() { h.asyncProvision(cluster, resource.ID, tenantID, projectID, userID, spec) })
550554

551555
w.Header().Set("Content-Type", "application/json")
552556
w.WriteHeader(http.StatusAccepted)
@@ -708,14 +712,14 @@ func (h *ClusterHandler) Delete(w http.ResponseWriter, r *http.Request) {
708712

709713
_ = h.repo.UpdateStatus(r.Context(), id, models.StatusDeleting, "deletion requested", "")
710714

711-
go func() {
715+
h.tasks.Go(func() {
712716
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute)
713717
defer cancel()
714718
if err := cluster.DeleteCluster(ctx, resource.BackendUID); err != nil {
715719
h.log.Error().Err(err).Str("cluster", resource.Name).Msg("delete cluster failed")
716720
_ = h.repo.UpdateStatus(ctx, id, models.StatusFailed, "deletion failed: "+err.Error(), "")
717721
}
718-
}()
722+
})
719723

720724
w.WriteHeader(http.StatusAccepted)
721725
}
@@ -852,7 +856,9 @@ func (h *ClusterHandler) AddNodePool(w http.ResponseWriter, r *http.Request) {
852856
return
853857
}
854858

855-
go h.asyncAddPool(cluster, clusterID, clusterName, pool, mgmtNAD, tenantSubnetNAD, vmNamespace, req.ImageName)
859+
h.tasks.Go(func() {
860+
h.asyncAddPool(cluster, clusterID, clusterName, pool, mgmtNAD, tenantSubnetNAD, vmNamespace, req.ImageName)
861+
})
856862

857863
writeJSON(w, http.StatusAccepted, poolToResponse(pool))
858864
}
@@ -1006,7 +1012,9 @@ func (h *ClusterHandler) ScaleOrUpdateNodePool(w http.ResponseWriter, r *http.Re
10061012
writeError(w, http.StatusBadGateway, "cannot reach the cluster's zone: "+err.Error())
10071013
return
10081014
}
1009-
go h.asyncPatchPool(cluster, clusterName, pool, req.Count > 0, newCount, req.Taints != nil || req.Labels != nil, newTaints, newLabels)
1015+
h.tasks.Go(func() {
1016+
h.asyncPatchPool(cluster, clusterName, pool, req.Count > 0, newCount, req.Taints != nil || req.Labels != nil, newTaints, newLabels)
1017+
})
10101018

10111019
writeJSON(w, http.StatusAccepted, poolToResponse(pool))
10121020
}
@@ -1071,7 +1079,7 @@ func (h *ClusterHandler) RemoveNodePool(w http.ResponseWriter, r *http.Request)
10711079
h.log.Error().Err(err).Str("pool", poolName).Msg("mark pool deleting")
10721080
}
10731081

1074-
go h.asyncRemovePool(cluster, clusterID, clusterName, pool)
1082+
h.tasks.Go(func() { h.asyncRemovePool(cluster, clusterID, clusterName, pool) })
10751083

10761084
w.WriteHeader(http.StatusAccepted)
10771085
}

dc-api/internal/api/handlers/dns_zone.go

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import (
2323
"github.com/google/uuid"
2424
"github.com/rs/zerolog"
2525
"github.com/wso2/dc-api/internal/api/middleware"
26+
"github.com/wso2/dc-api/internal/async"
2627
"github.com/wso2/dc-api/internal/db"
2728
"github.com/wso2/dc-api/internal/models"
2829
"github.com/wso2/dc-api/internal/providers"
@@ -37,12 +38,15 @@ type PrivateDnsZoneHandler struct {
3738
resolve providers.Resolver
3839
defaultRegion string
3940
defaultZone string
40-
log zerolog.Logger
41+
// tasks tracks the async provisioning goroutines so shutdown can drain
42+
// them (bounded). May be nil (tests) — async.Group is nil-receiver-safe.
43+
tasks *async.Group
44+
log zerolog.Logger
4145
}
4246

4347
// NewPrivateDnsZoneHandler creates a PrivateDnsZoneHandler with injected dependencies.
44-
func NewPrivateDnsZoneHandler(repo *db.Repository, resolve providers.Resolver, defaultRegion, defaultZone string, log zerolog.Logger) *PrivateDnsZoneHandler {
45-
return &PrivateDnsZoneHandler{repo: repo, resolve: resolve, defaultRegion: defaultRegion, defaultZone: defaultZone, log: log}
48+
func NewPrivateDnsZoneHandler(repo *db.Repository, resolve providers.Resolver, defaultRegion, defaultZone string, tasks *async.Group, log zerolog.Logger) *PrivateDnsZoneHandler {
49+
return &PrivateDnsZoneHandler{repo: repo, resolve: resolve, defaultRegion: defaultRegion, defaultZone: defaultZone, tasks: tasks, log: log}
4650
}
4751

4852
// network resolves the NetworkProvider for the parent VNet's (region, zone).
@@ -253,9 +257,11 @@ func (h *PrivateDnsZoneHandler) CreateZone(w http.ResponseWriter, r *http.Reques
253257
}
254258

255259

256-
go h.asyncProvisionZone(network, zone.ID, tenantID, userID, vnet.BackendUID, models.DnsZoneSpec{
257-
ZoneName: req.Name,
258-
Description: req.Description,
260+
h.tasks.Go(func() {
261+
h.asyncProvisionZone(network, zone.ID, tenantID, userID, vnet.BackendUID, models.DnsZoneSpec{
262+
ZoneName: req.Name,
263+
Description: req.Description,
264+
})
259265
})
260266

261267
w.Header().Set("Content-Type", "application/json")
@@ -373,7 +379,7 @@ func (h *PrivateDnsZoneHandler) DeleteZone(w http.ResponseWriter, r *http.Reques
373379
return
374380
}
375381

376-
go func() {
382+
h.tasks.Go(func() {
377383
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
378384
defer cancel()
379385
if zone.BackendUID == "" {
@@ -386,7 +392,7 @@ func (h *PrivateDnsZoneHandler) DeleteZone(w http.ResponseWriter, r *http.Reques
386392
return
387393
}
388394
_ = h.repo.DeleteDNSZone(ctx, zoneID)
389-
}()
395+
})
390396

391397
w.WriteHeader(http.StatusAccepted)
392398
}

dc-api/internal/api/handlers/peering.go

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ import (
2727
"github.com/google/uuid"
2828
"github.com/rs/zerolog"
2929
"github.com/wso2/dc-api/internal/api/middleware"
30+
"github.com/wso2/dc-api/internal/async"
3031
"github.com/wso2/dc-api/internal/db"
3132
"github.com/wso2/dc-api/internal/models"
3233
"github.com/wso2/dc-api/internal/placement"
@@ -44,12 +45,15 @@ type PeeringHandler struct {
4445
resolve providers.Resolver
4546
defaultRegion string
4647
defaultZone string
47-
log zerolog.Logger
48+
// tasks tracks the async provisioning goroutines so shutdown can drain
49+
// them (bounded). May be nil (tests) — async.Group is nil-receiver-safe.
50+
tasks *async.Group
51+
log zerolog.Logger
4852
}
4953

5054
// NewPeeringHandler creates a PeeringHandler with injected dependencies.
51-
func NewPeeringHandler(repo *db.Repository, resolve providers.Resolver, defaultRegion, defaultZone string, log zerolog.Logger) *PeeringHandler {
52-
return &PeeringHandler{repo: repo, resolve: resolve, defaultRegion: defaultRegion, defaultZone: defaultZone, log: log}
55+
func NewPeeringHandler(repo *db.Repository, resolve providers.Resolver, defaultRegion, defaultZone string, tasks *async.Group, log zerolog.Logger) *PeeringHandler {
56+
return &PeeringHandler{repo: repo, resolve: resolve, defaultRegion: defaultRegion, defaultZone: defaultZone, tasks: tasks, log: log}
5357
}
5458

5559
// network resolves the NetworkProvider for (region, zone). Empty values resolve
@@ -274,7 +278,9 @@ func (h *PeeringHandler) Create(w http.ResponseWriter, r *http.Request) {
274278
PeerAddressSpace: peerVNet.AddressSpace,
275279
TransitCIDR: transitCIDR,
276280
}
277-
go h.asyncProvisionPeering(network, peering.ID, tenantID, userID, vnet.BackendUID, peerVNet.BackendUID, spec)
281+
h.tasks.Go(func() {
282+
h.asyncProvisionPeering(network, peering.ID, tenantID, userID, vnet.BackendUID, peerVNet.BackendUID, spec)
283+
})
278284

279285
resp := peeringToResponse(peering)
280286
w.Header().Set("Content-Type", "application/json")
@@ -436,7 +442,7 @@ func (h *PeeringHandler) Delete(w http.ResponseWriter, r *http.Request) {
436442
return
437443
}
438444

439-
go func() {
445+
h.tasks.Go(func() {
440446
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
441447
defer cancel()
442448
if peering.BackendUID == "" {
@@ -459,7 +465,7 @@ func (h *PeeringHandler) Delete(w http.ResponseWriter, r *http.Request) {
459465
Msg("release transit CIDR (non-fatal — CASCADE will clean up)")
460466
}
461467
_ = h.repo.DeletePeering(ctx, peeringID)
462-
}()
468+
})
463469

464470
w.WriteHeader(http.StatusAccepted)
465471
}

0 commit comments

Comments
 (0)