From c3ba469e1decabf49071684b2e9baca6b360b9e4 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 24 May 2026 17:30:05 +0400 Subject: [PATCH 1/4] fix: resolve marketplace bundle architecture blockers --- .github/workflows/helm-template-smoke.yml | 32 ++- cmd/serviceoffer-controller/main.go | 8 + cmd/serviceoffer-controller/main_test.go | 10 + docs/observability.md | 22 +- hack/migrate-bedag-raw-to-base.sh | 51 ++-- .../base/templates/obol-frontend-rbac.yaml | 53 ---- .../base/templates/obol-frontend.yaml | 28 +- internal/erc8004/types.go | 12 +- internal/erc8004/types_test.go | 26 ++ internal/serviceoffercontroller/controller.go | 13 +- .../serviceoffercontroller/identity_render.go | 8 +- internal/serviceoffercontroller/render.go | 18 +- .../serviceoffercontroller/render_test.go | 74 +++++ internal/x402/setup.go | 255 ++++++++++++++++++ internal/x402/setup_runtime_config_test.go | 103 +++++++ 15 files changed, 610 insertions(+), 103 deletions(-) delete mode 100644 internal/embed/infrastructure/base/templates/obol-frontend-rbac.yaml create mode 100644 internal/x402/setup_runtime_config_test.go diff --git a/.github/workflows/helm-template-smoke.yml b/.github/workflows/helm-template-smoke.yml index 27a9ed1f..9ee3fa57 100644 --- a/.github/workflows/helm-template-smoke.yml +++ b/.github/workflows/helm-template-smoke.yml @@ -53,7 +53,37 @@ jobs: helm template base "$workdir/base" \ --set dataDir=/data \ --set network=mainnet \ - > /dev/null + > "$workdir/base-rendered.yaml" + + # Kubernetes object identity must be unique within one rendered + # chart. Helm will happily render duplicate apiVersion/kind/name + # tuples and leave the actual outcome to manifest ordering; this + # caught the duplicated obol-frontend ClusterRole/Binding review bug. + awk ' + function flush() { + if (api && kind && name) { + key = api "/" kind "/" ns "/" name + count[key]++ + } + api = kind = name = ns = ""; inmeta = 0 + } + /^---/ { flush(); next } + /^apiVersion:/ { api = $2; next } + /^kind:/ { kind = $2; next } + /^metadata:/ { inmeta = 1; next } + inmeta && /^ name:/ { name = $2; next } + inmeta && /^ namespace:/ { ns = $2; next } + /^[^ ]/ && $0 !~ /^(apiVersion|kind|metadata):/ { inmeta = 0 } + END { + flush() + for (k in count) { + if (count[k] > 1) { + print count[k] " " k + dup = 1 + } + } + exit dup + }' "$workdir/base-rendered.yaml" - name: helm template ./cloudflared run: | diff --git a/cmd/serviceoffer-controller/main.go b/cmd/serviceoffer-controller/main.go index 6e81cd65..28be8287 100644 --- a/cmd/serviceoffer-controller/main.go +++ b/cmd/serviceoffer-controller/main.go @@ -92,6 +92,7 @@ func runWithLeaderElection(ctx context.Context, cfg *rest.Config, controller *se log.Printf("serviceoffer-controller: became leader %s", podName) if err := controller.Run(ctx, workers); err != nil { log.Printf("controller run: %v", err) + os.Exit(controllerRunExitCode(err)) } }, OnStoppedLeading: func() { @@ -110,6 +111,13 @@ func runWithLeaderElection(ctx context.Context, cfg *rest.Config, controller *se }) } +func controllerRunExitCode(err error) int { + if err != nil { + return 1 + } + return 0 +} + func loadConfig(kubeconfig string) (*rest.Config, error) { if kubeconfig != "" { return clientcmd.BuildConfigFromFlags("", kubeconfig) diff --git a/cmd/serviceoffer-controller/main_test.go b/cmd/serviceoffer-controller/main_test.go index addb8856..5a1badb4 100644 --- a/cmd/serviceoffer-controller/main_test.go +++ b/cmd/serviceoffer-controller/main_test.go @@ -1,6 +1,7 @@ package main import ( + "errors" "os" "path/filepath" "testing" @@ -61,6 +62,15 @@ func TestLeaderElectionDefaults(t *testing.T) { } } +func TestControllerRunExitCode(t *testing.T) { + if got := controllerRunExitCode(nil); got != 0 { + t.Fatalf("controllerRunExitCode(nil) = %d, want 0", got) + } + if got := controllerRunExitCode(errors.New("informer died")); got != 1 { + t.Fatalf("controllerRunExitCode(error) = %d, want 1", got) + } +} + const minimalKubeconfig = `apiVersion: v1 kind: Config clusters: diff --git a/docs/observability.md b/docs/observability.md index e4daa5f2..0b98e449 100644 --- a/docs/observability.md +++ b/docs/observability.md @@ -59,7 +59,7 @@ PR #530 swapped it to `increase()` over an explicit window. +------------------------------+ | x402-verifier (stateless) | | - in-memory counters | - | - labels: route, | + | - labels: | | offer_namespace, | | offer_name, chain, | | asset_symbol | @@ -77,8 +77,8 @@ PR #530 swapped it to `increase()` over an explicit window. v +------------------------------+ | Pre-aggregated series | - | offer:x402_revenue:7d_by_offer - | offer:x402_paid_requests:7d_by_offer + | x402:revenue:7d_by_offer + | x402:revenue:7d_by_offer_chain +---------------+--------------+ | | PromQL queries @@ -191,10 +191,11 @@ Naming follows the standard Prometheus pattern: Examples we ship: -- `offer:x402_revenue:7d_by_offer` — revenue aggregated to the `offer` level, - base metric is `x402_revenue`, operation is `increase` over `7d` grouped - `by_offer`. -- `offer:x402_paid_requests:7d_by_offer` — same shape for paid request count. +- `x402:revenue:7d_by_offer` — paid request count aggregated to the offer + level over the last 7d. The frontend multiplies this by the ServiceOffer + price table to display revenue. +- `x402:revenue:7d_by_offer_chain_asset_symbol` — same window, retaining + chain and settlement-token facets for per-token and per-chain views. Rules: @@ -202,9 +203,9 @@ Rules: is a lie (Prometheus has no "lifetime"). The window in the name must match the window in the expression. 2. **Use `increase()` over an explicit range, not `sum()` of the raw counter.** - See PR #530 — the original rule did `sum(by offer) (x402_revenue_total)` and - silently zeroed every time the verifier pod restarted. The fixed rule is - `sum by (offer_namespace, offer_name) (increase(x402_revenue_total[7d]))`. + See PR #530 — the original rule did `sum(by offer) (charged_requests_total)` + and silently zeroed every time the verifier pod restarted. The fixed rule is + `sum by (offer_namespace, offer_name) (increase(obol_x402_verifier_charged_requests_total[7d]))`. 3. **Keep the window aligned with retention.** Recording a `30d` rule with 8d retention is a footgun: the rule sees nulls and silently produces nothing. @@ -226,7 +227,6 @@ Concrete examples: | Label | Source | Why include it | |-------------------|------------------|-------------------------------------------| -| `route` | offer CR pattern | Direct query facet, bounded by # offers | | `offer_namespace` | offer CR meta | Tenancy facet | | `offer_name` | offer CR meta | Per-offer breakdown | | `chain` | offer CR payment | "Revenue by chain" is a real question | diff --git a/hack/migrate-bedag-raw-to-base.sh b/hack/migrate-bedag-raw-to-base.sh index d1c7d278..191f2821 100755 --- a/hack/migrate-bedag-raw-to-base.sh +++ b/hack/migrate-bedag-raw-to-base.sh @@ -26,9 +26,20 @@ ORPHAN_RELEASES=( ) migrate_one() { - local target="$1" + local kind="$1" + local name="$2" + local namespace="${3:-}" local current - current=$(kubectl get "$target" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || true) + + local resource="${kind}/${name}" + local target="$resource" + local -a ns_args=() + if [[ -n "$namespace" ]]; then + ns_args=(-n "$namespace") + target="$resource -n $namespace" + fi + + current=$(kubectl get "$resource" "${ns_args[@]}" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || true) if [[ "$current" == "base" ]]; then echo " $target: already on base, skipping" return 0 @@ -38,10 +49,10 @@ migrate_one() { else echo " $target: was on '$current', migrating to base" fi - kubectl annotate "$target" \ + kubectl annotate "$resource" "${ns_args[@]}" \ meta.helm.sh/release-name=base \ meta.helm.sh/release-namespace=kube-system --overwrite >/dev/null - kubectl label "$target" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null + kubectl label "$resource" "${ns_args[@]}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null } echo "==> Scanning for resources owned by legacy bedag/raw releases..." @@ -51,10 +62,10 @@ for release in "${ORPHAN_RELEASES[@]}"; do -A -o json 2>/dev/null \ | jq -r --arg rel "$release" '.items[] | select(.metadata.annotations["meta.helm.sh/release-name"] == $rel) - | "\(.kind)/\(.metadata.name)\(if .metadata.namespace then " -n " + .metadata.namespace else "" end)"' \ - | while read -r target; do - [[ -z "$target" ]] && continue - migrate_one "$target" + | [.kind, .metadata.name, (.metadata.namespace // "")] | @tsv' \ + | while IFS=$'\t' read -r kind name namespace; do + [[ -z "$kind" || -z "$name" ]] && continue + migrate_one "$kind" "$name" "$namespace" done done @@ -63,17 +74,25 @@ done # in the namespaces base now owns. echo "==> Adopting unowned resources base will now claim..." declare -a UNOWNED_TARGETS=( - "namespace/erpc" - "namespace/obol-frontend" - "prometheusrule/x402-verifier -n x402" + "namespace erpc " + "namespace obol-frontend " + "prometheusrule x402-verifier x402" ) for target in "${UNOWNED_TARGETS[@]}"; do - if kubectl get $target >/dev/null 2>&1; then - owner=$(kubectl get $target -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || true) + IFS=$'\t' read -r kind name namespace <<< "$target" + resource="${kind}/${name}" + ns_args=() + display="$resource" + if [[ -n "$namespace" ]]; then + ns_args=(-n "$namespace") + display="$resource -n $namespace" + fi + if kubectl get "$resource" "${ns_args[@]}" >/dev/null 2>&1; then + owner=$(kubectl get "$resource" "${ns_args[@]}" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || true) if [[ -z "$owner" || "$owner" == "base" ]]; then - echo " $target: $([ -z "$owner" ] && echo "adopting" || echo "already base")" - kubectl annotate $target meta.helm.sh/release-name=base meta.helm.sh/release-namespace=kube-system --overwrite >/dev/null - kubectl label $target app.kubernetes.io/managed-by=Helm --overwrite >/dev/null + echo " $display: $([ -z "$owner" ] && echo "adopting" || echo "already base")" + kubectl annotate "$resource" "${ns_args[@]}" meta.helm.sh/release-name=base meta.helm.sh/release-namespace=kube-system --overwrite >/dev/null + kubectl label "$resource" "${ns_args[@]}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null fi fi done diff --git a/internal/embed/infrastructure/base/templates/obol-frontend-rbac.yaml b/internal/embed/infrastructure/base/templates/obol-frontend-rbac.yaml deleted file mode 100644 index 038df594..00000000 --- a/internal/embed/infrastructure/base/templates/obol-frontend-rbac.yaml +++ /dev/null @@ -1,53 +0,0 @@ ---- -# RBAC for the obol-frontend pod's ServiceAccount. -# -# The frontend pod uses this SA's bearer token to: -# - Discover OpenClaw / Hermes instances (namespaces, pods, configmaps) -# - List + mutate ServiceOffer CRs (sell-modal + pause/resume/delete row actions) -# - List PurchaseRequest CRs (My Purchases page; never writes) -# -# The frontend is local-only behind the obol.stack hostname restriction -# (the operator owns the cluster), so this is a single trust boundary. -# Defense-in-depth note: the `secrets` rule is intentionally omitted — -# no code path reads them and the SA token shouldn't have that reach. -# /status subresources are omitted from PurchaseRequest because the -# controller is the only writer. -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRole -metadata: - name: obol-frontend-openclaw-discovery - labels: - app.kubernetes.io/name: obol-frontend -rules: - - apiGroups: [""] - resources: ["namespaces"] - verbs: ["get", "list"] - - apiGroups: [""] - resources: ["pods", "configmaps"] - verbs: ["get", "list"] - # ServiceOffer CRD — frontend sell modal creates offers, row actions - # pause/resume (annotation patch) and delete. - - apiGroups: ["obol.org"] - resources: ["serviceoffers", "serviceoffers/status"] - verbs: ["get", "list", "create", "update", "patch", "delete"] - # PurchaseRequest CRD — frontend My Purchases page lists buyer-side - # records. Read-only; agent buy.py and the controller are the writers. - - apiGroups: ["obol.org"] - resources: ["purchaserequests"] - verbs: ["get", "list", "watch"] - ---- -apiVersion: rbac.authorization.k8s.io/v1 -kind: ClusterRoleBinding -metadata: - name: obol-frontend-openclaw-discovery - labels: - app.kubernetes.io/name: obol-frontend -roleRef: - apiGroup: rbac.authorization.k8s.io - kind: ClusterRole - name: obol-frontend-openclaw-discovery -subjects: - - kind: ServiceAccount - name: obol-frontend - namespace: obol-frontend diff --git a/internal/embed/infrastructure/base/templates/obol-frontend.yaml b/internal/embed/infrastructure/base/templates/obol-frontend.yaml index 397a192e..77a4c806 100644 --- a/internal/embed/infrastructure/base/templates/obol-frontend.yaml +++ b/internal/embed/infrastructure/base/templates/obol-frontend.yaml @@ -54,12 +54,16 @@ spec: port: 3000 --- -# obol-frontend RBAC for OpenClaw instance discovery and ServiceOffer -# CRUD from the frontend sell modal. The ClusterRoleBinding subject -# references the `obol-frontend` ServiceAccount that the upstream -# `obol/obol-app` chart creates — the binding applies fine even if -# the SA does not exist yet, and starts granting permissions the -# moment the SA appears. +# obol-frontend RBAC for the pod ServiceAccount. +# +# Keep this as the single frontend RBAC template. A prior bundle carried a +# second obol-frontend-rbac.yaml template with the same ClusterRole and +# ClusterRoleBinding names, which made the rendered chart order-dependent. +# +# The frontend is local-only behind the obol.stack hostname restriction +# (the operator owns the cluster), so this is a single trust boundary. +# Defense-in-depth note: the `secrets` rule is intentionally omitted — no +# frontend code path reads them and the SA token should not have that reach. apiVersion: rbac.authorization.k8s.io/v1 kind: ClusterRole metadata: @@ -71,12 +75,22 @@ rules: resources: ["namespaces"] verbs: ["get", "list"] - apiGroups: [""] - resources: ["pods", "configmaps", "secrets"] + resources: ["pods", "configmaps"] verbs: ["get", "list"] # ServiceOffer CRD — frontend sell modal creates offers - apiGroups: ["obol.org"] resources: ["serviceoffers", "serviceoffers/status"] verbs: ["get", "list", "create", "update", "patch", "delete"] + # PurchaseRequest CRD — My Purchases lists agent buys. Read-only: the + # agent and controller own writes. + - apiGroups: ["obol.org"] + resources: ["purchaserequests", "purchaserequests/status"] + verbs: ["get", "list", "watch"] + # RegistrationRequest CRD — listing rows surface ERC-8004 registration + # state. Read-only: the controller owns writes. + - apiGroups: ["obol.org"] + resources: ["registrationrequests", "registrationrequests/status"] + verbs: ["get", "list", "watch"] --- apiVersion: rbac.authorization.k8s.io/v1 diff --git a/internal/erc8004/types.go b/internal/erc8004/types.go index 3e22d8c5..85463f51 100644 --- a/internal/erc8004/types.go +++ b/internal/erc8004/types.go @@ -33,11 +33,13 @@ const RegistrationType = "https://eips.ethereum.org/EIPS/eip-8004#registration-v // For OASF entries (name="OASF"), Skills and Domains provide machine-readable // taxonomy for agent discovery. See https://schema.oasf.outshift.com/ type ServiceDef struct { - Name string `json:"name"` // e.g., "web", "A2A", "MCP", "OASF" - Endpoint string `json:"endpoint,omitempty"` // full URL (omitempty for OASF entries) - Version string `json:"version,omitempty"` // protocol version (SHOULD per spec) - Skills []string `json:"skills,omitempty"` // OASF skill taxonomy paths - Domains []string `json:"domains,omitempty"` // OASF domain taxonomy paths + Name string `json:"name"` // e.g., "web", "A2A", "MCP", "OASF" + Endpoint string `json:"endpoint,omitempty"` // full URL (omitempty for OASF entries) + Version string `json:"version,omitempty"` // protocol version (SHOULD per spec) + Skills []string `json:"skills,omitempty"` // OASF skill taxonomy paths + Domains []string `json:"domains,omitempty"` // OASF domain taxonomy paths + Available *bool `json:"available,omitempty"` // false only while the service is draining + DrainEndsAt string `json:"drainEndsAt,omitempty"` // RFC3339 timestamp for draining services } // OnChainReg links the registration to its on-chain record. diff --git a/internal/erc8004/types_test.go b/internal/erc8004/types_test.go index 80bd025e..e8fdd9fb 100644 --- a/internal/erc8004/types_test.go +++ b/internal/erc8004/types_test.go @@ -179,6 +179,32 @@ func TestServiceDef_VersionOptional(t *testing.T) { } } +func TestServiceDef_DrainMetadataSerializesFalseAvailability(t *testing.T) { + available := false + svc := ServiceDef{ + Name: "web", + Endpoint: "https://example.com/services/demo", + Available: &available, + DrainEndsAt: "2026-05-24T12:00:00Z", + } + + data, err := json.Marshal(svc) + if err != nil { + t.Fatalf("Marshal: %v", err) + } + + var m map[string]json.RawMessage + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("unmarshal to map: %v", err) + } + if string(m["available"]) != "false" { + t.Fatalf("available = %s, want false in %s", m["available"], data) + } + if string(m["drainEndsAt"]) != `"2026-05-24T12:00:00Z"` { + t.Fatalf("drainEndsAt = %s, want timestamp in %s", m["drainEndsAt"], data) + } +} + func TestOnChainReg_AgentIDNumeric(t *testing.T) { reg := OnChainReg{ AgentID: 42, diff --git a/internal/serviceoffercontroller/controller.go b/internal/serviceoffercontroller/controller.go index 7cbd759b..8226f06a 100644 --- a/internal/serviceoffercontroller/controller.go +++ b/internal/serviceoffercontroller/controller.go @@ -439,8 +439,17 @@ func (c *Controller) reconcileOffer(ctx context.Context, key string) error { if !ready { setCondition(&status, "ModelReady", "False", "WaitingForAgent", "Referenced Agent is not yet Ready") setCondition(&status, "UpstreamHealthy", "False", "WaitingForAgent", "Referenced Agent is not yet Ready") - setCondition(&status, "PaymentGateReady", "False", "WaitingForAgent", "Referenced Agent is not yet Ready") - setCondition(&status, "RoutePublished", "False", "WaitingForAgent", "Referenced Agent is not yet Ready") + if offer.DrainExpired(time.Now()) { + if err := c.deleteRouteChildren(ctx, offer); err != nil { + return err + } + setCondition(&status, "Draining", "False", "Drained", fmt.Sprintf("Drain ended at %s; route torn down", offer.DrainEndsAt().UTC().Format(time.RFC3339))) + setCondition(&status, "PaymentGateReady", "False", "Drained", "Offer drained; payment gate removed") + setCondition(&status, "RoutePublished", "False", "Drained", "Offer drained; route removed") + } else { + setCondition(&status, "PaymentGateReady", "False", "WaitingForAgent", "Referenced Agent is not yet Ready") + setCondition(&status, "RoutePublished", "False", "WaitingForAgent", "Referenced Agent is not yet Ready") + } setCondition(&status, "Ready", "False", "WaitingForAgent", "Referenced Agent is not yet Ready") return c.updateOfferStatus(ctx, raw, status) } diff --git a/internal/serviceoffercontroller/identity_render.go b/internal/serviceoffercontroller/identity_render.go index 347d0faf..89623d36 100644 --- a/internal/serviceoffercontroller/identity_render.go +++ b/internal/serviceoffercontroller/identity_render.go @@ -150,10 +150,10 @@ func buildIdentityRegistrationServices(offers []*monetizeapi.ServiceOffer, baseU baseURL = strings.TrimRight(baseURL, "/") services := make([]erc8004.ServiceDef, 0, len(offers)*2) for _, offer := range offers { - services = append(services, erc8004.ServiceDef{ + services = append(services, serviceDefWithDrain(offer, erc8004.ServiceDef{ Name: "web", Endpoint: baseURL + offer.EffectivePath(), - }) + })) if len(offer.Spec.Registration.Skills) > 0 || len(offer.Spec.Registration.Domains) > 0 { services = append(services, erc8004.ServiceDef{ Name: "OASF", @@ -163,11 +163,11 @@ func buildIdentityRegistrationServices(offers []*monetizeapi.ServiceOffer, baseU }) } for _, svc := range offer.Spec.Registration.Services { - services = append(services, erc8004.ServiceDef{ + services = append(services, serviceDefWithDrain(offer, erc8004.ServiceDef{ Name: svc.Name, Endpoint: svc.Endpoint, Version: svc.Version, - }) + })) } } return services diff --git a/internal/serviceoffercontroller/render.go b/internal/serviceoffercontroller/render.go index 24586000..0f65fa89 100644 --- a/internal/serviceoffercontroller/render.go +++ b/internal/serviceoffercontroller/render.go @@ -726,10 +726,10 @@ func buildRegistrationServices(owner *monetizeapi.ServiceOffer, offers []*moneti services := make([]erc8004.ServiceDef, 0, len(ordered)*2) for _, offer := range ordered { - services = append(services, erc8004.ServiceDef{ + services = append(services, serviceDefWithDrain(offer, erc8004.ServiceDef{ Name: "web", Endpoint: baseURL + offer.EffectivePath(), - }) + })) if len(offer.Spec.Registration.Skills) > 0 || len(offer.Spec.Registration.Domains) > 0 { services = append(services, erc8004.ServiceDef{ Name: "OASF", @@ -739,16 +739,26 @@ func buildRegistrationServices(owner *monetizeapi.ServiceOffer, offers []*moneti }) } for _, service := range offer.Spec.Registration.Services { - services = append(services, erc8004.ServiceDef{ + services = append(services, serviceDefWithDrain(offer, erc8004.ServiceDef{ Name: service.Name, Endpoint: service.Endpoint, Version: service.Version, - }) + })) } } return services } +func serviceDefWithDrain(offer *monetizeapi.ServiceOffer, svc erc8004.ServiceDef) erc8004.ServiceDef { + if offer == nil || !offer.IsDraining() || offer.DrainExpired(time.Now()) { + return svc + } + available := false + svc.Available = &available + svc.DrainEndsAt = offer.DrainEndsAt().UTC().Format(time.RFC3339) + return svc +} + // offerPublishedForRegistration reports whether an offer should appear // in the operator's ERC-8004 registration document as a live, gated // service. Draining offers stay in the document with available=false diff --git a/internal/serviceoffercontroller/render_test.go b/internal/serviceoffercontroller/render_test.go index 199e6721..286e1763 100644 --- a/internal/serviceoffercontroller/render_test.go +++ b/internal/serviceoffercontroller/render_test.go @@ -409,6 +409,80 @@ func TestBuildRegistrationServices_IncludesOwnerWhenOwnerNotYetPublished(t *test } } +func TestBuildRegistrationServices_IncludesDrainMetadata(t *testing.T) { + drainAt := metav1.NewTime(time.Now()) + grace := metav1.Duration{Duration: time.Hour} + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "draining", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Path: "/services/draining", + DrainAt: &drainAt, + DrainGracePeriod: &grace, + Registration: monetizeapi.ServiceOfferRegistration{ + Enabled: true, + Services: []monetizeapi.ServiceOfferService{ + {Name: "A2A", Endpoint: "https://example.com/a2a", Version: "0.2.1"}, + }, + }, + }, + Status: monetizeapi.ServiceOfferStatus{ + Conditions: []monetizeapi.Condition{ + {Type: "ModelReady", Status: "True"}, + {Type: "UpstreamHealthy", Status: "True"}, + {Type: "PaymentGateReady", Status: "True"}, + {Type: "RoutePublished", Status: "True"}, + }, + }, + } + + services := buildRegistrationServices(offer, []*monetizeapi.ServiceOffer{offer}, "https://example.com") + if len(services) != 2 { + t.Fatalf("services = %+v, want web + A2A", services) + } + for _, svc := range services { + if svc.Available == nil { + t.Fatalf("%s missing available=false drain marker: %+v", svc.Name, svc) + } + if *svc.Available { + t.Fatalf("%s available = true, want false during drain: %+v", svc.Name, svc) + } + if _, err := time.Parse(time.RFC3339, svc.DrainEndsAt); err != nil { + t.Fatalf("%s drainEndsAt = %q is not RFC3339: %v", svc.Name, svc.DrainEndsAt, err) + } + } +} + +func TestBuildIdentityRegistrationServices_IncludesDrainMetadata(t *testing.T) { + drainAt := metav1.NewTime(time.Now()) + grace := metav1.Duration{Duration: 30 * time.Minute} + offer := &monetizeapi.ServiceOffer{ + ObjectMeta: metav1.ObjectMeta{Name: "identity-drain", Namespace: "demo"}, + Spec: monetizeapi.ServiceOfferSpec{ + Path: "/services/identity-drain", + DrainAt: &drainAt, + DrainGracePeriod: &grace, + Registration: monetizeapi.ServiceOfferRegistration{ + Services: []monetizeapi.ServiceOfferService{ + {Name: "MCP", Endpoint: "https://example.com/mcp", Version: "2025-06-18"}, + }, + }, + }, + } + + services := buildIdentityRegistrationServices([]*monetizeapi.ServiceOffer{offer}, "https://example.com") + if len(services) != 2 { + t.Fatalf("services = %+v, want web + MCP", services) + } + for _, svc := range services { + if svc.Available == nil || *svc.Available { + t.Fatalf("%s missing available=false drain marker: %+v", svc.Name, svc) + } + if _, err := time.Parse(time.RFC3339, svc.DrainEndsAt); err != nil { + t.Fatalf("%s drainEndsAt = %q is not RFC3339: %v", svc.Name, svc.DrainEndsAt, err) + } + } +} + func TestBuildRegistrationConfigMap_PublishesAggregatedAgentRegistration(t *testing.T) { readyConditions := []monetizeapi.Condition{ {Type: "ModelReady", Status: "True"}, diff --git a/internal/x402/setup.go b/internal/x402/setup.go index 4811ceb6..8af3fe8e 100644 --- a/internal/x402/setup.go +++ b/internal/x402/setup.go @@ -92,9 +92,18 @@ func EnsureVerifier(cfg *config.Config) error { return fmt.Errorf("refresh infrastructure defaults: %w", err) } + kubeconfigPath := filepath.Join(cfg.ConfigDir, "kubeconfig.yaml") + snapshots, err := preserveMutableRuntimeConfigMaps(cfg, kubeconfigPath) + if err != nil { + return fmt.Errorf("snapshot mutable runtime configmaps: %w", err) + } + if err := helmfileSyncBaseRelease(cfg); err != nil { return fmt.Errorf("helmfile sync %s: %w", baseReleaseName, err) } + if err := restoreMutableRuntimeConfigMaps(cfg, kubeconfigPath, snapshots); err != nil { + return fmt.Errorf("restore mutable runtime configmaps: %w", err) + } // Populate the CA bundle after deploying the verifier so TLS verification // of the facilitator works immediately. Idempotent — safe to call multiple times. @@ -103,6 +112,252 @@ func EnsureVerifier(cfg *config.Config) error { return nil } +type mutableConfigMapSnapshot struct { + Name string + Namespace string + Data map[string]string +} + +var mutableRuntimeConfigMaps = []mutableConfigMapSnapshot{ + {Name: "litellm-config", Namespace: "llm"}, + {Name: "x402-buyer-config", Namespace: "llm"}, + {Name: "x402-buyer-auths", Namespace: "llm"}, +} + +// preserveMutableRuntimeConfigMaps snapshots ConfigMaps whose data is mutated +// at runtime by `obol model setup`, PurchaseRequest reconciliation, or the +// buyer auth-pool flow. `EnsureVerifier` must sync the base release so the +// verifier uses canonical Helm ownership, but the base chart contains only +// bootstrap defaults for these objects. Without this snapshot/restore pass, +// `obol x402 setup` can erase configured models and buyer auth state. +func preserveMutableRuntimeConfigMaps(cfg *config.Config, kubeconfigPath string) ([]mutableConfigMapSnapshot, error) { + out := make([]mutableConfigMapSnapshot, 0, len(mutableRuntimeConfigMaps)) + for _, item := range mutableRuntimeConfigMaps { + data, found, err := readConfigMapData(cfg, kubeconfigPath, item.Namespace, item.Name) + if err != nil { + return nil, err + } + if !found || len(data) == 0 { + continue + } + out = append(out, mutableConfigMapSnapshot{Name: item.Name, Namespace: item.Namespace, Data: data}) + } + return out, nil +} + +func restoreMutableRuntimeConfigMaps(cfg *config.Config, kubeconfigPath string, snapshots []mutableConfigMapSnapshot) error { + for _, snap := range snapshots { + current, _, err := readConfigMapData(cfg, kubeconfigPath, snap.Namespace, snap.Name) + if err != nil { + return err + } + data, err := mergeRuntimeConfigMapData(snap.Name, current, snap.Data) + if err != nil { + return err + } + if len(data) == 0 { + continue + } + manifest, err := configMapDataManifest(snap.Namespace, snap.Name, data) + if err != nil { + return err + } + if err := kubectl.ApplyServerSideForceConflicts(filepath.Join(cfg.BinDir, "kubectl"), kubeconfigPath, manifest, "helm"); err != nil { + return err + } + } + return nil +} + +func readConfigMapData(cfg *config.Config, kubeconfigPath, namespace, name string) (map[string]string, bool, error) { + raw, err := kubectl.Output(filepath.Join(cfg.BinDir, "kubectl"), kubeconfigPath, + "get", "configmap", name, "-n", namespace, "-o", "json") + if err != nil { + if strings.Contains(err.Error(), "not found") || strings.Contains(err.Error(), "NotFound") { + return nil, false, nil + } + return nil, false, fmt.Errorf("get configmap %s/%s: %w", namespace, name, err) + } + var obj struct { + Data map[string]string `json:"data"` + } + if err := json.Unmarshal([]byte(raw), &obj); err != nil { + return nil, false, fmt.Errorf("parse configmap %s/%s: %w", namespace, name, err) + } + return obj.Data, true, nil +} + +func mergeRuntimeConfigMapData(name string, current, previous map[string]string) (map[string]string, error) { + if name == "litellm-config" { + currentRaw := current["config.yaml"] + previousRaw := previous["config.yaml"] + if strings.TrimSpace(previousRaw) == "" { + return current, nil + } + if strings.TrimSpace(currentRaw) == "" { + return previous, nil + } + merged, err := mergeLiteLLMConfig(currentRaw, previousRaw) + if err != nil { + return nil, err + } + out := copyStringMap(current) + out["config.yaml"] = merged + return out, nil + } + + out := copyStringMap(previous) + for k, v := range current { + out[k] = v + } + return out, nil +} + +func mergeLiteLLMConfig(currentRaw, previousRaw string) (string, error) { + var current map[string]any + if err := yaml.Unmarshal([]byte(currentRaw), ¤t); err != nil { + return "", fmt.Errorf("parse current LiteLLM config: %w", err) + } + if current == nil { + current = map[string]any{} + } + + var previous map[string]any + if err := yaml.Unmarshal([]byte(previousRaw), &previous); err != nil { + return "", fmt.Errorf("parse previous LiteLLM config: %w", err) + } + if previous == nil { + previous = map[string]any{} + } + + merged := copyAnyMap(previous) + for key, value := range current { + merged[key] = value + } + + models, err := mergeLiteLLMModelLists(current["model_list"], previous["model_list"]) + if err != nil { + return "", err + } + if len(models) > 0 { + merged["model_list"] = models + } + + for _, key := range []string{"general_settings", "litellm_settings"} { + if liteLLMValueEmpty(current[key]) && !liteLLMValueEmpty(previous[key]) { + merged[key] = previous[key] + } + } + + mergedRaw, err := yaml.Marshal(merged) + if err != nil { + return "", fmt.Errorf("serialize merged LiteLLM config: %w", err) + } + return string(mergedRaw), nil +} + +func mergeLiteLLMModelLists(currentRaw, previousRaw any) ([]any, error) { + current, err := liteLLMModelList(currentRaw) + if err != nil { + return nil, fmt.Errorf("parse current LiteLLM model_list: %w", err) + } + previous, err := liteLLMModelList(previousRaw) + if err != nil { + return nil, fmt.Errorf("parse previous LiteLLM model_list: %w", err) + } + + merged := append([]any{}, current...) + byName := make(map[string]bool, len(current)) + for _, entry := range current { + if name := liteLLMModelName(entry); name != "" { + byName[name] = true + } + } + for _, entry := range previous { + name := liteLLMModelName(entry) + if name == "" { + continue + } + if byName[name] { + continue + } + byName[name] = true + merged = append(merged, entry) + } + return merged, nil +} + +func liteLLMModelList(value any) ([]any, error) { + if value == nil { + return nil, nil + } + list, ok := value.([]any) + if !ok { + return nil, fmt.Errorf("expected sequence, got %T", value) + } + return list, nil +} + +func liteLLMModelName(entry any) string { + switch typed := entry.(type) { + case map[string]any: + if name, ok := typed["model_name"].(string); ok { + return strings.TrimSpace(name) + } + case map[any]any: + if name, ok := typed["model_name"].(string); ok { + return strings.TrimSpace(name) + } + } + return "" +} + +func liteLLMValueEmpty(value any) bool { + switch typed := value.(type) { + case nil: + return true + case string: + return strings.TrimSpace(typed) == "" + case []any: + return len(typed) == 0 + case map[string]any: + return len(typed) == 0 + case map[any]any: + return len(typed) == 0 + default: + return false + } +} + +func configMapDataManifest(namespace, name string, data map[string]string) ([]byte, error) { + obj := map[string]any{ + "apiVersion": "v1", + "kind": "ConfigMap", + "metadata": map[string]string{ + "name": name, + "namespace": namespace, + }, + "data": data, + } + return yaml.Marshal(obj) +} + +func copyStringMap(in map[string]string) map[string]string { + out := make(map[string]string, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func copyAnyMap(in map[string]any) map[string]any { + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + // helmfileSyncBaseRelease runs `helmfile --selector name=base sync` // against the defaults helmfile rendered into $OBOL_CONFIG_DIR/defaults. // This is the same invocation pattern used by `internal/stack.syncDefaults` diff --git a/internal/x402/setup_runtime_config_test.go b/internal/x402/setup_runtime_config_test.go new file mode 100644 index 00000000..bc0f22e3 --- /dev/null +++ b/internal/x402/setup_runtime_config_test.go @@ -0,0 +1,103 @@ +package x402 + +import ( + "testing" + + "gopkg.in/yaml.v3" +) + +func TestMergeRuntimeConfigMapData_LiteLLMPreservesUserModels(t *testing.T) { + current := map[string]string{"config.yaml": ` +model_list: + - model_name: paid/* + litellm_params: + model: openai/* + api_base: http://127.0.0.1:8402/v1 + api_key: unused +general_settings: + master_key: os.environ/LITELLM_MASTER_KEY +`} + previous := map[string]string{"config.yaml": ` +model_list: + - model_name: paid/qwen36 + litellm_params: + model: openai/qwen36-apex-i-compact + api_base: http://silvermesh.v1337.lan:8081/v1 + api_key: unused +litellm_settings: + drop_params: true +`} + + merged, err := mergeRuntimeConfigMapData("litellm-config", current, previous) + if err != nil { + t.Fatalf("mergeRuntimeConfigMapData: %v", err) + } + + var parsed struct { + ModelList []struct { + ModelName string `yaml:"model_name"` + } `yaml:"model_list"` + GeneralSettings map[string]any `yaml:"general_settings"` + LiteLLMSettings map[string]any `yaml:"litellm_settings"` + } + if err := yaml.Unmarshal([]byte(merged["config.yaml"]), &parsed); err != nil { + t.Fatalf("parse merged yaml: %v\n%s", err, merged["config.yaml"]) + } + + got := map[string]bool{} + for _, entry := range parsed.ModelList { + got[entry.ModelName] = true + } + for _, want := range []string{"paid/*", "paid/qwen36"} { + if !got[want] { + t.Fatalf("merged config missing model %q:\n%s", want, merged["config.yaml"]) + } + } + if parsed.GeneralSettings["master_key"] == nil { + t.Fatalf("current general_settings should be preserved:\n%s", merged["config.yaml"]) + } + if parsed.LiteLLMSettings["drop_params"] == nil { + t.Fatalf("previous litellm_settings should be restored when current is empty:\n%s", merged["config.yaml"]) + } +} + +func TestMergeRuntimeConfigMapData_BuyerConfigPreservesRuntimeKeys(t *testing.T) { + current := map[string]string{"new.json": `{"new":true}`} + previous := map[string]string{ + "alice.json": `{"auths":["a"]}`, + "new.json": `{"old":true}`, + } + + merged, err := mergeRuntimeConfigMapData("x402-buyer-auths", current, previous) + if err != nil { + t.Fatalf("mergeRuntimeConfigMapData: %v", err) + } + if merged["alice.json"] != previous["alice.json"] { + t.Fatalf("runtime key was not preserved: %#v", merged) + } + if merged["new.json"] != current["new.json"] { + t.Fatalf("current key should win on conflicts: %#v", merged) + } +} + +func TestConfigMapDataManifest_RendersConfigMap(t *testing.T) { + manifest, err := configMapDataManifest("llm", "x402-buyer-config", map[string]string{ + "demo.json": `{"endpoint":"http://example"}`, + }) + if err != nil { + t.Fatalf("configMapDataManifest: %v", err) + } + + var parsed struct { + APIVersion string `yaml:"apiVersion"` + Kind string `yaml:"kind"` + Metadata map[string]string `yaml:"metadata"` + Data map[string]string `yaml:"data"` + } + if err := yaml.Unmarshal(manifest, &parsed); err != nil { + t.Fatalf("manifest is not yaml: %v\n%s", err, manifest) + } + if parsed.Kind != "ConfigMap" || parsed.Metadata["namespace"] != "llm" || parsed.Data["demo.json"] == "" { + t.Fatalf("unexpected manifest: %#v\n%s", parsed, manifest) + } +} From 82cbfae8a7f7624771ad9b270fdffe04d129e6e9 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 24 May 2026 17:37:22 +0400 Subject: [PATCH 2/4] chore: remove pre-release migration script --- .github/release-template.md | 7 --- docs/upgrade-from-pre-pr-523.md | 82 ------------------------ hack/migrate-bedag-raw-to-base.sh | 101 ------------------------------ 3 files changed, 190 deletions(-) delete mode 100644 docs/upgrade-from-pre-pr-523.md delete mode 100755 hack/migrate-bedag-raw-to-base.sh diff --git a/.github/release-template.md b/.github/release-template.md index fd72746e..c67dc086 100644 --- a/.github/release-template.md +++ b/.github/release-template.md @@ -96,13 +96,6 @@ repositories or docs.] ## Breaking changes / Migration notes - [Delete this section if there are no breaking changes.] -- **Upgrading from a pre-PR #523 cluster**: PR #523 relocated six `bedag/raw` - helmfile releases into the `base` chart. Existing clusters must run - `bash hack/migrate-bedag-raw-to-base.sh` once before `obol stack up` to - transfer Helm ownership annotations; otherwise `helm upgrade base` fails - with `invalid ownership metadata`. See - [`docs/upgrade-from-pre-pr-523.md`](../docs/upgrade-from-pre-pr-523.md). - Fresh installs are unaffected. ## Known issues diff --git a/docs/upgrade-from-pre-pr-523.md b/docs/upgrade-from-pre-pr-523.md deleted file mode 100644 index 1b9b5bf7..00000000 --- a/docs/upgrade-from-pre-pr-523.md +++ /dev/null @@ -1,82 +0,0 @@ -# Upgrading clusters created before PR #523 - -PR [#523](https://github.com/ObolNetwork/obol-stack/pull/523) relocates six -`bedag/raw` helmfile releases into the `base` chart so the stack has one -source of truth for everything it ships in the `erpc`, `obol-frontend`, and -`llm` namespaces. - -**Fresh installs are unaffected.** This page only applies if you are -upgrading a cluster that was created **before** PR #523 was merged. - -## Symptom - -Running `obol stack up` on a pre-#523 cluster fails during `helm upgrade base` -with errors of the form: - -``` -Error: UPGRADE FAILED: exists and cannot be imported into the -current release: invalid ownership metadata; annotation validation error: -key "meta.helm.sh/release-name" must equal "base"; current value is -"" -``` - -Helm refuses to "adopt" resources owned by another release. About ten -resources are affected (Namespaces, HTTPRoutes, Middlewares, ConfigMaps, -PrometheusRule, PodMonitor, ClusterRole/Binding) — enough that hand-fixing -them is error prone. - -## When to run the migration script - -- **Run once**, **before** `obol stack up`, against any cluster created - before PR #523 merged. -- The script is **idempotent** — safe to re-run if `obol stack up` is - interrupted or if you migrate one cluster at a time. -- Fresh clusters (`obol stack init && obol stack up` on an empty machine) - do **not** need it. - -```bash -# Optional: point at a non-default kubeconfig -export KUBECONFIG="$HOME/.config/obol/kubeconfig.yaml" - -bash hack/migrate-bedag-raw-to-base.sh -obol stack up -``` - -## What the script does - -It re-annotates the affected resources so Helm treats them as members of -the `base` release: - -``` -meta.helm.sh/release-name=base -meta.helm.sh/release-namespace=kube-system -app.kubernetes.io/managed-by=Helm -``` - -It covers the legacy `bedag/raw` releases removed by PR #523: - -| Legacy release | Namespace | -|---|---| -| `obol-frontend-rbac` | `obol-frontend` | -| `obol-frontend-httproute` | `obol-frontend` | -| `erpc-httproute` | `erpc` | -| `erpc-x402-middleware` | `erpc` | -| `erpc-metadata` | `erpc` | -| `llm-buyer-podmonitor` | `llm` | -| `x402-verifier-podmonitor` | `x402` (partial-upgrade clusters from before PR #513 hardening) | - -It also adopts a small set of resources that may exist with no Helm -ownership at all (`namespace/erpc`, `namespace/obol-frontend`, -`prometheusrule/x402-verifier` in `x402`) so the next `helm upgrade base` -can manage them cleanly. - -## Verifying the migration - -After running the script, `obol stack up` should succeed without the -`invalid ownership metadata` errors. To spot-check a single resource: - -```bash -kubectl get httproute -n obol-frontend obol-frontend \ - -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}{"\n"}' -# → base -``` diff --git a/hack/migrate-bedag-raw-to-base.sh b/hack/migrate-bedag-raw-to-base.sh deleted file mode 100755 index 191f2821..00000000 --- a/hack/migrate-bedag-raw-to-base.sh +++ /dev/null @@ -1,101 +0,0 @@ -#!/usr/bin/env bash -# Migrate resources from the legacy bedag/raw helmfile releases to the -# base chart that now owns them after obol-stack PR #523. -# -# Symptom this fixes: -# Error: UPGRADE FAILED: exists and cannot be imported -# into the current release: invalid ownership metadata -# -# Run once before `obol stack up` against any cluster deployed before -# PR #523 merged. -# -# Idempotent — safe to re-run. - -set -euo pipefail - -: "${KUBECONFIG:=$HOME/.config/obol/kubeconfig.yaml}" - -ORPHAN_RELEASES=( - obol-frontend-rbac - obol-frontend-httproute - erpc-httproute - erpc-x402-middleware - erpc-metadata - llm-buyer-podmonitor - x402-verifier-podmonitor # killed by PR #513's hardening; keep in case partial-upgrade clusters still have it -) - -migrate_one() { - local kind="$1" - local name="$2" - local namespace="${3:-}" - local current - - local resource="${kind}/${name}" - local target="$resource" - local -a ns_args=() - if [[ -n "$namespace" ]]; then - ns_args=(-n "$namespace") - target="$resource -n $namespace" - fi - - current=$(kubectl get "$resource" "${ns_args[@]}" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || true) - if [[ "$current" == "base" ]]; then - echo " $target: already on base, skipping" - return 0 - fi - if [[ -z "$current" ]]; then - echo " $target: no Helm metadata, adopting into base" - else - echo " $target: was on '$current', migrating to base" - fi - kubectl annotate "$resource" "${ns_args[@]}" \ - meta.helm.sh/release-name=base \ - meta.helm.sh/release-namespace=kube-system --overwrite >/dev/null - kubectl label "$resource" "${ns_args[@]}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null -} - -echo "==> Scanning for resources owned by legacy bedag/raw releases..." -for release in "${ORPHAN_RELEASES[@]}"; do - echo "release: $release" - kubectl get all,clusterrole,clusterrolebinding,role,rolebinding,configmap,httproute,middleware,podmonitor,servicemonitor,prometheusrule,referencegrant,namespace \ - -A -o json 2>/dev/null \ - | jq -r --arg rel "$release" '.items[] - | select(.metadata.annotations["meta.helm.sh/release-name"] == $rel) - | [.kind, .metadata.name, (.metadata.namespace // "")] | @tsv' \ - | while IFS=$'\t' read -r kind name namespace; do - [[ -z "$kind" || -z "$name" ]] && continue - migrate_one "$kind" "$name" "$namespace" - done -done - -# Some resources were never Helm-owned (e.g. PrometheusRule x402-verifier may have -# been created via kubectl apply somewhere). Adopt them into base too if they exist -# in the namespaces base now owns. -echo "==> Adopting unowned resources base will now claim..." -declare -a UNOWNED_TARGETS=( - "namespace erpc " - "namespace obol-frontend " - "prometheusrule x402-verifier x402" -) -for target in "${UNOWNED_TARGETS[@]}"; do - IFS=$'\t' read -r kind name namespace <<< "$target" - resource="${kind}/${name}" - ns_args=() - display="$resource" - if [[ -n "$namespace" ]]; then - ns_args=(-n "$namespace") - display="$resource -n $namespace" - fi - if kubectl get "$resource" "${ns_args[@]}" >/dev/null 2>&1; then - owner=$(kubectl get "$resource" "${ns_args[@]}" -o jsonpath='{.metadata.annotations.meta\.helm\.sh/release-name}' 2>/dev/null || true) - if [[ -z "$owner" || "$owner" == "base" ]]; then - echo " $display: $([ -z "$owner" ] && echo "adopting" || echo "already base")" - kubectl annotate "$resource" "${ns_args[@]}" meta.helm.sh/release-name=base meta.helm.sh/release-namespace=kube-system --overwrite >/dev/null - kubectl label "$resource" "${ns_args[@]}" app.kubernetes.io/managed-by=Helm --overwrite >/dev/null - fi - fi -done - -echo "" -echo "✓ Migration complete. You may now run 'obol stack up'." From 94418dbc6bb21bd49cb724702e2b3c7d51078f80 Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 24 May 2026 17:39:17 +0400 Subject: [PATCH 3/4] docs: warn pre-release testers about stack reset --- .github/release-template.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/.github/release-template.md b/.github/release-template.md index c67dc086..18643721 100644 --- a/.github/release-template.md +++ b/.github/release-template.md @@ -96,6 +96,18 @@ repositories or docs.] ## Breaking changes / Migration notes - [Delete this section if there are no breaking changes.] +- **Pre-release tester warning**: If you ran an unreleased marketplace or + chart-consolidation branch before this release, `obol stack up` may fail + with Helm `invalid ownership metadata` errors for resources that moved into + the `base` chart. This is not a supported production migration path. Back up + anything you need from the local test stack, then recreate it: + + ```bash + obol stack down + obol stack purge --force + obol stack init + obol stack up + ``` ## Known issues From 46189cd48348533d1a8d60cf0e8add5a4e5b89af Mon Sep 17 00:00:00 2001 From: bussyjd Date: Sun, 24 May 2026 17:46:17 +0400 Subject: [PATCH 4/4] docs: clarify pre-release ownership warning --- .github/release-template.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/.github/release-template.md b/.github/release-template.md index 18643721..7172a71b 100644 --- a/.github/release-template.md +++ b/.github/release-template.md @@ -98,9 +98,10 @@ repositories or docs.] - [Delete this section if there are no breaking changes.] - **Pre-release tester warning**: If you ran an unreleased marketplace or chart-consolidation branch before this release, `obol stack up` may fail - with Helm `invalid ownership metadata` errors for resources that moved into - the `base` chart. This is not a supported production migration path. Back up - anything you need from the local test stack, then recreate it: + with Helm `invalid ownership metadata` errors for resources or namespaces + that moved into the `base` chart. This is not a supported production + migration path. Back up anything you need from the local test stack, then + recreate it: ```bash obol stack down