Skip to content

Commit 117c630

Browse files
mjudeikisclaude
andauthored
fix(infrastructure): operator recovers kro helm release stuck in pending-* (#342)
* fix(infrastructure): operator recovers kro helm release stuck in pending-* An interrupted helm operation (typically the operator pod killed mid-upgrade during an image rollout) leaves the kro release in a pending-upgrade / pending-install / pending-rollback state. Helm then refuses every subsequent operation with "another operation (install/upgrade/rollback) is in progress", and since nothing clears pending state, the operator's 2-minute retries wedge permanently (KroReleased=False, HelmFailed) until a human runs helm rollback. EnsureKroRelease now clears a wedged release before upgrading: - pending-upgrade / pending-rollback → roll back to the newest deployed/ superseded revision (a known-good state exists); - pending-install → uninstall the half-applied release (nothing to roll back to) so the upgrade reinstalls cleanly. The decision logic is a pure, unit-tested function (planRecovery). Also routed all helm invocations through a shared runHelm helper that honors the in-cluster runtime (empty kubeconfig → no KUBECONFIG override), fixing DeleteKroRelease which previously always set KUBECONFIG even for the in-cluster case. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * remove old api references --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent b986679 commit 117c630

14 files changed

Lines changed: 234 additions & 898 deletions

File tree

AGENTS.md

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -282,7 +282,6 @@ Each suite has a dedicated Make target. Most spin up their own hub on fixed port
282282
| `make e2e-external-kcp` | `external_kcp` | kcp via Helm in kind |
283283
| `make e2e-provider` | `provider` | Provider provisioning (quickstart) |
284284
| `make e2e-provider-flags` | `providerflags` | `--providers` flag mechanics (dep validation, filtering) |
285-
| `make e2e-infrastructure` | `infrastructure` | Infra provider cross-tenant isolation (stub mode, port 18082) |
286285
| `make e2e-tilt-cluster` | `tiltcluster` | Against a live `make tilt-cluster` multi-shard stack |
287286
| `make e2e-all` | all | Builds hub+agent images, runs everything (~30m) |
288287

Makefile

Lines changed: 1 addition & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
.PHONY: dev-edge-create dev-run-edge build test lint fix-lint codegen crds clean certs dev-setup run-dex run-hub run-hub-static run-hub-embedded run-hub-embedded-static run-hub-standalone run-hub-embedded-graphql run-kcp dev-login dev-login-static dev-create-workload dev dev-infra dev-run-kcp path boilerplate verify-boilerplate verify-codegen ldflags tools docker-build docker-build-hub docker-build-agent docker-build-dex docker-push-dex verify help-dev dev-status dev-clean-hooks helm-build-local helm-push-local helm-clean build-quickstart-provider build-quickstart-provider-portal build-kuery-provider build-kuery-provider-portal run-provider-kuery kuery-db-up kuery-db-down install-provider-kuery init-provider-kuery uninstall-provider-kuery run-provider-quickstart install-provider-quickstart init-provider-quickstart uninstall-provider-quickstart build-infrastructure-provider build-infrastructure-provider-portal codegen-infrastructure-provider run-provider-infrastructure install-provider-infrastructure init-provider-infrastructure uninstall-provider-infrastructure build-app-studio-provider build-app-studio-provider-portal codegen-app-studio-provider app-studio-db-up app-studio-db-down run-provider-app-studio install-provider-app-studio init-provider-app-studio uninstall-provider-app-studio build-code-provider build-code-provider-portal codegen-code-provider run-provider-code install-provider-code init-provider-code uninstall-provider-code dev-kro-up dev-kro-down dev-kro-seed dev-kro-register-self e2e-infrastructure portal-provider-symlinks build-mcp-provider-portal build-kubernetes-edges-provider-portal build-server-edges-provider-portal e2e-provider e2e-provider-flags e2e-provider-all
1+
.PHONY: dev-edge-create dev-run-edge build test lint fix-lint codegen crds clean certs dev-setup run-dex run-hub run-hub-static run-hub-embedded run-hub-embedded-static run-hub-standalone run-hub-embedded-graphql run-kcp dev-login dev-login-static dev-create-workload dev dev-infra dev-run-kcp path boilerplate verify-boilerplate verify-codegen ldflags tools docker-build docker-build-hub docker-build-agent docker-build-dex docker-push-dex verify help-dev dev-status dev-clean-hooks helm-build-local helm-push-local helm-clean build-quickstart-provider build-quickstart-provider-portal build-kuery-provider build-kuery-provider-portal run-provider-kuery kuery-db-up kuery-db-down install-provider-kuery init-provider-kuery uninstall-provider-kuery run-provider-quickstart install-provider-quickstart init-provider-quickstart uninstall-provider-quickstart build-infrastructure-provider build-infrastructure-provider-portal codegen-infrastructure-provider run-provider-infrastructure install-provider-infrastructure init-provider-infrastructure uninstall-provider-infrastructure build-app-studio-provider build-app-studio-provider-portal codegen-app-studio-provider app-studio-db-up app-studio-db-down run-provider-app-studio install-provider-app-studio init-provider-app-studio uninstall-provider-app-studio build-code-provider build-code-provider-portal codegen-code-provider run-provider-code install-provider-code init-provider-code uninstall-provider-code dev-kro-up dev-kro-down dev-kro-seed dev-kro-register-self portal-provider-symlinks build-mcp-provider-portal build-kubernetes-edges-provider-portal build-server-edges-provider-portal e2e-provider e2e-provider-flags e2e-provider-all
22

33
BINDIR ?= bin
44
GOFLAGS ?=
@@ -582,19 +582,6 @@ e2e-provider-flags: build-hub ## Run --providers flag mechanics suite
582582
## Run both provider suites back-to-back (sequential — they share port 2380).
583583
e2e-provider-all: e2e-provider e2e-provider-flags ## Run provider + provider-flags suites sequentially
584584

585-
## Provider-specific isolation suite for infrastructure. Spawns only
586-
## the infrastructure-provider binary on :18082 in stub mode (no kro
587-
## cluster, no hub, no kcp) and asserts cross-tenant reads / writes /
588-
## deletes are properly scoped. Independent of e2e-provider — they
589-
## don't share ports, so they could in principle run in parallel.
590-
E2E_KROMC_TIMEOUT ?= 5m
591-
e2e-infrastructure: build-infrastructure-provider ## Run infrastructure tenant-isolation e2e
592-
@test -z "$$(lsof -ti :18082 2>/dev/null)" || { \
593-
echo "port 18082 is in use; pkill infrastructure-provider and retry"; \
594-
exit 1; \
595-
}
596-
go test ./test/e2e/suites/infrastructure/... -v -timeout $(E2E_KROMC_TIMEOUT) $(if $(E2E_FLAGS),-args $(E2E_FLAGS))
597-
598585
## Tilt-cluster suite: runs against an ALREADY-RUNNING operator-deployed,
599586
## multi-shard Tilt stack (start it in another terminal with `make tilt-cluster`).
600587
## Unlike the other e2e suites it does NOT spawn its own processes — it connects

docs/infrastructure-architecture.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -418,7 +418,7 @@ Note what tenants and the portal know about: `Template`, `Redis`. Nothing in thi
418418
| **B** | CachedResource publishing Templates | A tenant workspace with the APIBinding sees Templates as a read-only resource. `kubectl get templates -A` from a tenant returns the catalog. |
419419
| **C** | kro backend + APIExport VW wiring | Backend interface implemented for kro. The platform binary starts the kro multicluster-runtime pointed at the provider's APIExport VW. A tenant applies a Redis CR; kro reconciles it in the management cluster within 10s; status syncs back; delete propagates. Orphan sweeper for deleted tenant workspaces in scope. |
420420
| **D** | UI + MCP migration | Portal main app and dashboard tile read Templates + per-template CRs via GraphQL. MCP tools (`infrastructure__list_templates`, `__provision`, …) become kcp API calls. Old REST endpoints get a deprecation banner. |
421-
| **E** | Cleanup | REST handlers gone. `tenantHash`, `kedge-tenants-*` convention, per-request header identity all gone. `make e2e-infrastructure` exercises only the kcp path. |
421+
| **E** | Cleanup | REST handlers gone. `tenantHash`, `kedge-tenants-*` convention, per-request header identity all gone. The REST-surface e2e suite (`make e2e-infrastructure`) is removed; isolation is exercised via the kcp path. |
422422

423423
A through C land the new platform + the first backend (~2.5 weeks). D adds another week (mostly portal rewrites). E is a day of deletions.
424424

providers/infrastructure/README.md

Lines changed: 6 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ helm CLI.
130130

131131
| Surface | Where |
132132
|---|---|
133-
| HTTP REST | `server/``/api/templates`, `/api/instances` |
133+
| HTTP server | `server/``/healthz`, portal SPA, `/mcp` |
134134
| MCP transport | `mcpserver/``/mcp`, `/mcp/sse` (6 `kro_*` tools) |
135135
| Central kro client | `kro/``ResourceGraphDefinition` discovery + instance lifecycle |
136136
| Tenant kcp client | `tenant/` — per-tenant `cloud-credentials` Secret resolution |
@@ -236,20 +236,12 @@ npm --prefix portal run build
236236
go run .
237237
# → listening on :8081 (kro=*kro.stubClient tenant=false mcp=true)
238238

239-
# 3. Smoke tests:
239+
# 3. Smoke test: liveness.
240240
curl -s localhost:8081/healthz
241-
curl -s localhost:8081/api/templates | jq '.items[].name'
242-
curl -s localhost:8081/api/templates/postgres | jq '.template.inputsSchema'
243-
244-
# 4. Provision flow (dev mode lets ?tenant= replace the X-Kedge-Tenant header).
245-
export KEDGE_DEV_ALLOW_TENANT_QUERY=true
246-
curl -s -X POST 'localhost:8081/api/instances?tenant=dev&user=alice' \
247-
-H 'Content-Type: application/json' \
248-
-d '{"templateName":"postgres","name":"foo","values":{"name":"foo","size":"medium"}}'
249-
curl -s 'localhost:8081/api/instances?tenant=dev' | jq
250-
curl -s -X DELETE 'localhost:8081/api/instances/foo?tenant=dev' -i
251-
252-
# 5. MCP tools/list (note: SSE response — pipe through `tail`).
241+
242+
# 4. MCP tools/list (note: SSE response — pipe through `head`). Templates
243+
# and instances are NOT served as REST — they are kro_* MCP tools and,
244+
# in a real cluster, CRDs read/written directly against kcp.
253245
curl -s -X POST -H 'Content-Type: application/json' \
254246
-H 'Accept: application/json, text/event-stream' \
255247
-d '{"jsonrpc":"2.0","id":1,"method":"tools/list","params":{}}' \

providers/infrastructure/examples/rgds/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ kro/templates.go`) looks for these keys verbatim:
1414
|---|---|
1515
| `kedge.faros.sh/expose=true` (label) | gates visibility — required |
1616
| `kedge.faros.sh/template-name` (label) | catalog slug; defaults to `metadata.name` |
17-
| `kedge.faros.sh/template-version` (label) | required by `POST /api/instances` for safety |
17+
| `kedge.faros.sh/template-version` (label) | required when provisioning, for safety |
1818
| `kedge.faros.sh/category` (label) | filter chip in the catalog grid |
1919
| `kedge.faros.sh/cloud` (label) | filter chip + maps credential schema |
2020
| `kedge.faros.sh/display-name` (annotation) | human-readable name |

providers/infrastructure/examples/rgds/simple-webapp.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
#
88
# kedge.faros.sh/expose=true → mandatory; gates visibility
99
# kedge.faros.sh/template-name → catalog slug (the URL segment)
10-
# kedge.faros.sh/template-version → required by POST /api/instances
10+
# kedge.faros.sh/template-version → required when provisioning
1111
# kedge.faros.sh/category, /cloud → optional facet chips in UI
1212
# kedge.faros.sh/display-name → human-readable name
1313
# kedge.faros.sh/description → short blurb

providers/infrastructure/kro/client.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ type Client interface {
5151
DeleteInstance(ctx context.Context, tenantPath, name string) error
5252
}
5353

54-
// TemplateFilter is the GET /api/templates query, normalized.
54+
// TemplateFilter narrows a template listing by category and cloud.
5555
type TemplateFilter struct {
5656
Category string
5757
Cloud string

providers/infrastructure/kro/types.go

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -32,7 +32,7 @@ import (
3232
// JSON-schema-shaped InputsSchema the DynamicForm consumes.
3333
type Template struct {
3434
// Name is the RGD's metadata.name; it doubles as the template
35-
// identifier used in API URLs (POST /api/instances {templateName}).
35+
// identifier callers reference when provisioning an instance.
3636
Name string `json:"name"`
3737
// DisplayName, Description, Category, Cloud are pulled from the
3838
// RGD's labels/annotations (see kedge.faros.sh/* convention in
@@ -43,7 +43,7 @@ type Template struct {
4343
Category string `json:"category,omitempty"`
4444
Cloud string `json:"cloud,omitempty"`
4545
// Version is read from the kedge.faros.sh/template-version label.
46-
// Required by POST /api/instances so we never silently provision
46+
// Required when provisioning so we never silently provision
4747
// against a different RGD generation than the user previewed.
4848
Version string `json:"version,omitempty"`
4949
// IconURL is an optional asset URL stored in the
@@ -134,7 +134,7 @@ const (
134134
// you want consumers' bookmarks to keep working.
135135
LabelTemplateName = "kedge.faros.sh/template-name"
136136
// LabelTemplateVersion pins a semver to the RGD revision. Required
137-
// in POST /api/instances so a chart bump doesn't silently change
137+
// when provisioning so a chart bump doesn't silently change
138138
// what gets provisioned.
139139
LabelTemplateVersion = "kedge.faros.sh/template-version"
140140
// LabelCategory lets the catalog UI render filter chips.

providers/infrastructure/operator/helm.go

Lines changed: 136 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -12,9 +12,11 @@ package operator
1212

1313
import (
1414
"context"
15+
"encoding/json"
1516
"fmt"
1617
"os"
1718
"os/exec"
19+
"strconv"
1820

1921
"github.com/faroshq/provider-infrastructure/apis/v1alpha1"
2022
"github.com/faroshq/provider-infrastructure/install"
@@ -26,6 +28,14 @@ import (
2628
// into this module. The kcp-kubeconfig Secret kro mounts is seeded separately
2729
// (install.SeedKroClusterFromKubeconfig) before this runs.
2830
func EnsureKroRelease(ctx context.Context, runtimeKubeconfigPath string, kro v1alpha1.KroSpec) error {
31+
// Clear any release wedged in a pending-* state by an interrupted prior helm
32+
// operation before attempting the upgrade — otherwise helm refuses with
33+
// "another operation (install/upgrade/rollback) is in progress" and the
34+
// operator's retries loop forever (nothing else clears pending state).
35+
if err := recoverPendingRelease(ctx, runtimeKubeconfigPath, kro); err != nil {
36+
return err
37+
}
38+
2939
args := []string{
3040
"upgrade", "--install", kro.ReleaseName, kro.Chart,
3141
"--version", kro.Version,
@@ -51,13 +61,7 @@ func EnsureKroRelease(ctx context.Context, runtimeKubeconfigPath string, kro v1a
5161
args = append(args, "--set", k+"="+v)
5262
}
5363

54-
cmd := exec.CommandContext(ctx, "helm", args...)
55-
// An empty path means "in-cluster runtime": run helm with no KUBECONFIG
56-
// override so it uses the pod's in-cluster service account.
57-
if runtimeKubeconfigPath != "" {
58-
cmd.Env = append(os.Environ(), "KUBECONFIG="+runtimeKubeconfigPath)
59-
}
60-
if out, err := cmd.CombinedOutput(); err != nil {
64+
if out, err := runHelm(ctx, runtimeKubeconfigPath, args...); err != nil {
6165
return fmt.Errorf("helm upgrade --install %s: %w\n%s", kro.ReleaseName, err, string(out))
6266
}
6367
return nil
@@ -66,11 +70,132 @@ func EnsureKroRelease(ctx context.Context, runtimeKubeconfigPath string, kro v1a
6670
// DeleteKroRelease uninstalls the kro Helm release (best-effort; a missing
6771
// release is not an error).
6872
func DeleteKroRelease(ctx context.Context, runtimeKubeconfigPath string, kro v1alpha1.KroSpec) error {
69-
cmd := exec.CommandContext(ctx, "helm", "uninstall", kro.ReleaseName,
70-
"--namespace", kro.Namespace, "--ignore-not-found")
71-
cmd.Env = append(os.Environ(), "KUBECONFIG="+runtimeKubeconfigPath)
72-
if out, err := cmd.CombinedOutput(); err != nil {
73+
if out, err := runHelm(ctx, runtimeKubeconfigPath, "uninstall", kro.ReleaseName,
74+
"--namespace", kro.Namespace, "--ignore-not-found"); err != nil {
7375
return fmt.Errorf("helm uninstall %s: %w\n%s", kro.ReleaseName, err, string(out))
7476
}
7577
return nil
7678
}
79+
80+
// recoverPendingRelease detects a kro release left in a pending-* state by an
81+
// interrupted helm operation (most commonly the operator pod killed mid-upgrade
82+
// during a rollout) and clears it so the subsequent upgrade can proceed. Helm
83+
// stores the in-flight state in the release record and refuses any new operation
84+
// until it resolves; nothing clears it automatically, so the operator's 2-minute
85+
// retries would otherwise wedge permanently.
86+
//
87+
// Strategy (see planRecovery):
88+
// - pending-upgrade / pending-rollback → roll back to the newest non-pending
89+
// revision (a known-good state exists);
90+
// - pending-install → the first install never completed, there is nothing to
91+
// roll back to, so uninstall the half-applied release and let the upgrade
92+
// reinstall it.
93+
func recoverPendingRelease(ctx context.Context, kubeconfigPath string, kro v1alpha1.KroSpec) error {
94+
status, err := helmReleaseStatus(ctx, kubeconfigPath, kro)
95+
if err != nil {
96+
// Release not found (fresh install) or status unreadable — nothing to
97+
// recover; let the upgrade --install proceed and surface any real error.
98+
return nil
99+
}
100+
101+
rev, hasDeployed := lastDeployedRevision(ctx, kubeconfigPath, kro)
102+
switch action, target := planRecovery(status, rev, hasDeployed); action {
103+
case recoverRollback:
104+
if out, rerr := runHelm(ctx, kubeconfigPath, "rollback", kro.ReleaseName,
105+
strconv.Itoa(target), "--namespace", kro.Namespace); rerr != nil {
106+
return fmt.Errorf("recover pending %q: helm rollback %s to %d: %w\n%s",
107+
status, kro.ReleaseName, target, rerr, string(out))
108+
}
109+
case recoverUninstall:
110+
if out, rerr := runHelm(ctx, kubeconfigPath, "uninstall", kro.ReleaseName,
111+
"--namespace", kro.Namespace, "--ignore-not-found"); rerr != nil {
112+
return fmt.Errorf("recover pending %q: helm uninstall %s: %w\n%s",
113+
status, kro.ReleaseName, rerr, string(out))
114+
}
115+
case recoverNone:
116+
// Healthy (deployed/failed) — helm upgrade --install handles it directly.
117+
}
118+
return nil
119+
}
120+
121+
// recoveryAction is the remediation planRecovery selects for a release status.
122+
type recoveryAction int
123+
124+
const (
125+
recoverNone recoveryAction = iota
126+
recoverRollback
127+
recoverUninstall
128+
)
129+
130+
// planRecovery maps a helm release status to the remediation needed before a
131+
// fresh upgrade can run. Pure (no I/O) so the decision logic is unit-testable;
132+
// the revision/hasDeployed inputs come from lastDeployedRevision.
133+
func planRecovery(status string, lastDeployedRev int, hasDeployed bool) (recoveryAction, int) {
134+
switch status {
135+
case "pending-upgrade", "pending-rollback":
136+
if hasDeployed {
137+
return recoverRollback, lastDeployedRev
138+
}
139+
// No good revision to fall back to — treat like a failed first install.
140+
return recoverUninstall, 0
141+
case "pending-install":
142+
return recoverUninstall, 0
143+
default:
144+
return recoverNone, 0
145+
}
146+
}
147+
148+
// helmReleaseStatus returns the release's current status (e.g. "deployed",
149+
// "pending-upgrade"). A non-nil error means the release was not found or its
150+
// status could not be read — callers treat that as "nothing to recover".
151+
func helmReleaseStatus(ctx context.Context, kubeconfigPath string, kro v1alpha1.KroSpec) (string, error) {
152+
out, err := runHelm(ctx, kubeconfigPath, "status", kro.ReleaseName,
153+
"--namespace", kro.Namespace, "-o", "json")
154+
if err != nil {
155+
return "", fmt.Errorf("helm status %s: %w\n%s", kro.ReleaseName, err, string(out))
156+
}
157+
var s struct {
158+
Info struct {
159+
Status string `json:"status"`
160+
} `json:"info"`
161+
}
162+
if err := json.Unmarshal(out, &s); err != nil {
163+
return "", fmt.Errorf("decode helm status %s: %w", kro.ReleaseName, err)
164+
}
165+
return s.Info.Status, nil
166+
}
167+
168+
// lastDeployedRevision returns the highest revision whose status is a known-good
169+
// rollback target (deployed or superseded), and whether such a revision exists.
170+
func lastDeployedRevision(ctx context.Context, kubeconfigPath string, kro v1alpha1.KroSpec) (int, bool) {
171+
out, err := runHelm(ctx, kubeconfigPath, "history", kro.ReleaseName,
172+
"--namespace", kro.Namespace, "-o", "json")
173+
if err != nil {
174+
return 0, false
175+
}
176+
var hist []struct {
177+
Revision int `json:"revision"`
178+
Status string `json:"status"`
179+
}
180+
if err := json.Unmarshal(out, &hist); err != nil {
181+
return 0, false
182+
}
183+
best, found := 0, false
184+
for _, h := range hist {
185+
if (h.Status == "deployed" || h.Status == "superseded") && h.Revision > best {
186+
best, found = h.Revision, true
187+
}
188+
}
189+
return best, found
190+
}
191+
192+
// runHelm runs the helm CLI, pointing it at the runtime cluster. An empty
193+
// kubeconfigPath means "in-cluster runtime": helm runs with no KUBECONFIG
194+
// override so it uses the pod's in-cluster service account.
195+
func runHelm(ctx context.Context, kubeconfigPath string, args ...string) ([]byte, error) {
196+
cmd := exec.CommandContext(ctx, "helm", args...)
197+
if kubeconfigPath != "" {
198+
cmd.Env = append(os.Environ(), "KUBECONFIG="+kubeconfigPath)
199+
}
200+
return cmd.CombinedOutput()
201+
}

0 commit comments

Comments
 (0)