fix(catalyst-api): #317↔#319 contract — preserve slim deployment record post-handover for redirect (closes #453) (#458)

#317's FinaliseHandover deleted the deployment record entirely, which
meant #319's `AdoptedAt` field was dormant — the post-handover redirect
at console.openova.io/sovereign/<id> 404'd instead of 301-ing to
console.<sovereign-fqdn>.

Fix: replace `store.Delete(id)` at the end of FinaliseHandover with a
slim-record save via the new `Deployment.SlimForHandover(adoptedAt)`
seam. The slim shape retains:
  - id, sovereignFQDN, orgName, orgEmail, startedAt (audit-minimum)
  - AdoptedAt = now() (redirect contract from #319 PR #451)
  - Status: "adopted"
  - closed eventsCh + done channels

Operational fields are zeroed: Result/tofuState, kubeconfig hash, PDM
reservation token, error, credentials. Consistent with §0
minimum-retention principle.

Tests:
  - TestFinaliseHandover_PreservesRedirectContract — drives FinaliseHandover
    then GET /api/v1/deployments/{id}, asserts adoptedAt + sovereignFQDN
    survive on JSON response and on disk via store.Load round-trip
  - TestSlimForHandover (table-driven) — full-record + minimal-record
    transforms; asserts audit fields kept, redirect field set,
    operational fields zeroed, credentials zeroed, channels closed
  - TestSlimForHandover_StoreRecordRoundTrip — JSON encode/decode
    cross-Pod-restart guard
  - TestFinaliseHandover_FullFlow extended with slim-shape assertions

Anti-duplication: SlimForHandover lives next to other Deployment methods
in deployments.go (canonical seam). FinaliseHandover modifies the same
file referenced in the issue (handover.go); no parallel binary or
script.

WBS row #453 → done; class line T453 wip → done.

Co-authored-by: hatiyildiz <hatiyildiz@noreply.github.com>
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
e3mrah 2026-05-01 19:52:58 +04:00 committed by GitHub
parent 51e24ea3b8
commit 18d59174d3
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 393 additions and 23 deletions

View File

@ -240,8 +240,7 @@ flowchart TB
T456 --> DOD
class PH0,PH1,PH2,PH3,PH4,PH5,PH6,PH7,PH8,SCAF phase
class T316,T317,T319,T327,T331,T338,T370,T371,T373,T374,T375,T376,T377,T378,T379,T380,T381,T382,T383,T384,T385,T387,T392,T425,T428,T429,T430,T438 done
class T453 wip
class T316,T317,T319,T327,T331,T338,T370,T371,T373,T374,T375,T376,T377,T378,T379,T380,T381,T382,T383,T384,T385,T387,T392,T425,T428,T429,T430,T438,T453 done
class T454,T455,T456 blocked
%% Clickable ticket numbers
@ -285,7 +284,7 @@ flowchart TB
**Honest read:**
- Phases 0-7 are **green at chart-level** — code is shipped, individual blueprints smoke-installed on contabo. Reconcile-chain status across all 23 is **❓ unknown** (see §2 right column).
- Phase 7 is **incomplete** — #453 (#317↔#319 contract reconciliation) is a known bug discovered during #319 implementation. The post-handover redirect is broken until #453 lands.
- Phase 7 is **complete at chart-level** — #453 (#317↔#319 contract reconciliation) merged: handover-finalisation now preserves the slim record so the post-handover redirect from console.openova.io/sovereign/<id> → console.<sovereign-fqdn> fires correctly. Live verification still pending Phase 8b (#455).
- Phase 8 is **the actual handover gate**. Three sub-tickets:
- **#454 (8a)**: live provision dry run on `test.omani.works` — surfaces every reconcile-chain bug
- **#455 (8b)**: handover + decommission cycle on `test.omani.works`
@ -450,7 +449,7 @@ If founder wants to amend ADR-0001 with §13 formalised (S3 vs SeaweedFS rule),
| #323 | 🟢 done (epic #320 IAM — POST-OMANTEL scope; here for cross-reference only) | [#452](https://github.com/openova-io/openova/pull/452) merged `783f7713` | UserAccess REST + UI editor |
| #324 | ⏸ parked (epic #320 IAM — POST-OMANTEL; agent stopped 2026-05-01 per scope rewrite) | — | — |
| #325 | ⏸ parked (epic #320 IAM — POST-OMANTEL; agent stopped 2026-05-01 per scope rewrite) | — | — |
| **#453** | 🟡 **in flight — Phase 7 cleanup; #317↔#319 contract reconciliation. #317's `FinaliseHandover` deletes the deployment record entirely, so #319's `AdoptedAt` field is dormant and the post-handover redirect at `console.openova.io/sovereign/<id>` is broken. Fix: preserve the slim record with `AdoptedAt`+`SovereignFQDN` populated; zero out operational fields. BLOCKS Phase 8b.** | (PR pending) | gates omantel handover loop |
| **#453** | 🟢 done — handover-finalisation now preserves slim record (id, sovereignFQDN, createdAt, createdBy, AdoptedAt) instead of deleting it; operational fields (tofuState, kubeconfig, Result, error, credentials) zeroed; redirect contract from #319 PR #451 now actually fires post-handover. New `Deployment.SlimForHandover(adoptedAt)` seam swaps the in-memory + on-disk record from `status: ready``status: adopted`. Tests: `TestFinaliseHandover_PreservesRedirectContract` (drives FinaliseHandover then GET /api/v1/deployments/{id}, asserts adoptedAt + sovereignFQDN survive on JSON response and on disk via store.Load round-trip) + `TestSlimForHandover` (table-driven full-record/minimal-record transform; asserts audit fields kept, redirect field set, operational fields/credentials zeroed, channels closed) + `TestSlimForHandover_StoreRecordRoundTrip` (JSON encode/decode survives Pod restart). All `go test ./...` green; `bash scripts/check-vendor-coupling.sh` exit 0 (HARD-FAIL mode). | (this PR) | catalyst-api `internal/handler/{handover.go,deployments.go,handover_test.go}` |
| **#454** | 🔒 **blocked — Phase 8a · live provision dry run on `test.omani.works`. Operator-driven (real Hetzner credit). Provisions a Sovereign via wizard; watches all 23 bp-* HelmReleases reach Ready=True; surfaces every reconcile-chain bug as a follow-up ticket. THIS is the integration-test gate for the chart-released → integration-tested → DoD-met progression.** | (depends on #453) | gates Phase 8b |
| **#455** | 🔒 **blocked — Phase 8b · handover + decommission cycle on `test.omani.works`. Runs handover-finalisation, verifies redirect (post-#453), runs customer-side decommission, verifies wipe + re-provision idempotency.** | (depends on #454) | gates Phase 8c |
| **#456** | 🔒 **blocked — Phase 8c · production `omantel.omani.works` run. DoD-met when this closes cleanly: omantel runs self-sufficient on Hetzner, killing contabo for 5 min has zero effect, customer admin kubectl works via Keycloak.** | (depends on #455) | THE DoD GATE |
@ -461,7 +460,7 @@ These are bugs we already know exist but cannot fix until Phase 8a exposes them
| # | Gap | Likely surfaces in | Fix vector |
|---|---|---|---|
| R1 | **#317↔#319 contract bug** — handover-finalisation deletes the deployment record; redirect can never read `AdoptedAt` | Phase 8b (redirect 404s instead of 301-ing) | [#453](https://github.com/openova-io/openova/issues/453) — IN FLIGHT, blocks Phase 8b |
| R1 | **#317↔#319 contract bug** — handover-finalisation deletes the deployment record; redirect can never read `AdoptedAt` | Phase 8b (redirect 404s instead of 301-ing) | [#453](https://github.com/openova-io/openova/issues/453) — DONE; live verification pending Phase 8b |
| R2 | **Crossplane `provider-hcloud` Healthy=True never observed** | Phase 8a — Provider may fail to install if RBAC / image pull issues | Surfaces as a Phase 8a sub-bug; fix in same iteration |
| R3 | **Cilium Gateway HTTPRoute admission untested** — bp-catalyst-platform smoke skipped HTTPRoute on contabo (no Gateway present) | Phase 8a (console.test.omani.works returns 404 / 502) | Likely a Gateway-class or sectionName mismatch; fix in same iteration |
| R4 | **bootstrap-kit reconcile order under load** — never run all 23 HRs together with real `dependsOn` chain | Phase 8a (some HRs stuck in dependency-wait or InstallFailed) | Iterate on chart `dependsOn` + `disableWait` flags |
@ -578,7 +577,7 @@ Per founder corrective 2026-05-01: **stop dispatching capacity-fill agents on po
**The only work that matters between now and DoD:**
1. **#453** (in flight) — fix #317↔#319 contract so the redirect works
1. **#453** ✅ done — #317↔#319 contract reconciled; FinaliseHandover preserves slim record so the redirect works
2. **#454** Phase 8a — operator runs the live test.omani.works provision (real Hetzner credit)
3. **Iterate on whatever 8a surfaces** (expect 3-5 bugs from §9a Risk register)
4. **#455** Phase 8b — handover + decommission cycle on test.omani.works

View File

@ -164,6 +164,67 @@ type Deployment struct {
liveWatcher *helmwatch.Watcher
}
// SlimForHandover returns a copy of the receiver retaining ONLY the
// fields a post-handover record needs:
//
// - id, sovereignFQDN, createdAt (StartedAt), createdBy (Request.OrgEmail)
// for audit;
// - AdoptedAt (= adoptedAt) for the redirect contract consumed by
// UI/router.tsx::maybeRedirectToCustomerConsole (issue #319).
//
// All operational/runtime state (Result, TofuState, kubeconfig path on
// Result.KubeconfigPath, PDM bearer hash, pdm reservation token,
// component states, error string, finishedAt) is zeroed. The slim
// shape is what is persisted by FinaliseHandover (issue #453) instead
// of deleting the record entirely — deletion would 404 the redirect
// fetch and the customer console would never be reached.
//
// Per docs/INVIOLABLE-PRINCIPLES.md §0 minimum-retention: Catalyst-Zero
// keeps ONLY the structural breadcrumb required for the redirect to
// fire; everything else lives on the Sovereign side post-handover.
//
// The returned Deployment carries closed eventsCh + done channels so
// any leftover SSE consumer attempts on the slim record terminate
// immediately (matching the fromRecord rehydration shape).
func (d *Deployment) SlimForHandover(adoptedAt time.Time) *Deployment {
closedCh := make(chan provisioner.Event)
closedDone := make(chan struct{})
close(closedCh)
close(closedDone)
stamped := adoptedAt.UTC()
d.mu.Lock()
id := d.ID
startedAt := d.StartedAt
orgName := d.Request.OrgName
orgEmail := d.Request.OrgEmail
sovereignFQDN := d.Request.SovereignFQDN
d.mu.Unlock()
return &Deployment{
ID: id,
Status: "adopted",
// Request retains ONLY the audit-minimum context (orgName,
// orgEmail, sovereignFQDN). All credentials + sizing + region
// detail are intentionally zeroed — they belong to the
// Sovereign side now.
Request: provisioner.Request{
OrgName: orgName,
OrgEmail: orgEmail,
SovereignFQDN: sovereignFQDN,
},
StartedAt: startedAt,
// FinishedAt is also "adoptedAt" semantically — set to the same
// stamp so the wizard's terminal-state surfaces show a coherent
// timeline.
FinishedAt: stamped,
AdoptedAt: &stamped,
eventsCh: closedCh,
done: closedDone,
}
}
// recordEvent appends ev to the durable history under the mutex, evicting
// the oldest entry when the buffer is at cap. Returns the event back so
// callers can fluently send it down the live channel.

View File

@ -22,7 +22,11 @@
// `secret/catalyst/tofu-phase0-archive`. On 200 OK from the new
// Sovereign, delete `/var/lib/catalyst/tofu/<sovereign-name>/`
// locally.
// 4. Delete the kubeconfig file on the PVC + the deployment record JSON.
// 4. Delete the kubeconfig file on the PVC; transform the deployment
// record JSON in-place to a slim post-handover state (id +
// sovereignFQDN + audit timestamps + AdoptedAt; operational fields
// zeroed) — the slim record is what fires the post-handover
// console redirect (issue #319 PR #451 contract; #453 reconciles).
//
// Step 4's "Hetzner token rotate" mentioned in the issue body is deferred
// to Crossplane Provider rotation (per #425, the canonical Day-2 IaC
@ -54,9 +58,12 @@
// - OpenBao writes go through internal/openbao.Client (canonical seam).
// - Tofu workdir cleanup uses os.RemoveAll on the existing
// provisioner.Provisioner.WorkDir/<sovereignName> path.
// - Deployment record + kubeconfig deletion uses the existing
// store.Store.Delete + os.Remove on h.kubeconfigsDir paths (same
// - Kubeconfig deletion uses os.Remove on h.kubeconfigsDir paths (same
// paths the wipe.go finalisation uses).
// - Deployment record post-handover is transformed to slim shape via
// Deployment.SlimForHandover + persisted with the existing
// store.Store.Save seam; the older Delete-then-redirect-404 path
// was the bug fixed by #453.
package handler
import (
@ -227,26 +234,39 @@ func (h *Handler) FinaliseHandover(w http.ResponseWriter, r *http.Request) {
}
}
// Step 4b — delete the on-disk deployment record. After this, GET
// /api/v1/deployments/{id} returns 404 (the in-memory entry is also
// dropped via h.deployments.Delete after a brief grace window so
// open SSE consumers can flush).
// Step 4b — slim the deployment record instead of deleting (#453,
// reconciles #317↔#319 contract).
//
// Issue #319 (PR #451) shipped the post-handover redirect: when the
// browser hits console.openova.io/sovereign/<id>, the
// provisionRoute.beforeLoad fetches /api/v1/deployments/<id>, reads
// `adoptedAt` + `sovereignFQDN`, and 301s to https://console.<fqdn>/.
// Deleting the record (the previous behaviour) made that fetch 404
// and the redirect could never fire — broken-by-design contract.
//
// The fix is to retain a slim post-handover record:
// - Audit minimum: id, sovereignFQDN, orgName, orgEmail, startedAt
// - Redirect contract: adoptedAt = now()
// All operational fields (Result/tofuState, kubeconfig hash, PDM
// reservation token, error, component states) are zeroed via
// SlimForHandover. The on-disk record after this Save is the
// structural breadcrumb required by §0 minimum-retention; nothing
// more.
adopted := dep.SlimForHandover(now)
h.deployments.Store(id, adopted)
if h.store != nil {
if err := h.store.Delete(id); err != nil {
resp.Errors = append(resp.Errors, "delete deployment record: "+err.Error())
if err := h.store.Save(adopted.toRecord()); err != nil {
resp.Errors = append(resp.Errors, "persist adopted record: "+err.Error())
} else {
resp.Steps.DeploymentRecordRemoved = true
}
} else {
// In-memory-only handler (test path) — slim shape was swapped
// in via Store above, that satisfies the redirect contract on
// its own.
resp.Steps.DeploymentRecordRemoved = true
}
// In-memory cleanup runs in a goroutine so any open SSE stream
// receives the `handover` event before the deployment id 404s. The
// 60s grace mirrors the wipe.go pattern.
go func() {
time.Sleep(60 * time.Second)
h.deployments.Delete(id)
}()
writeJSON(w, http.StatusOK, resp)
}

View File

@ -14,11 +14,13 @@ import (
"strings"
"sync"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/openbao"
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/provisioner"
"github.com/openova-io/openova/products/catalyst/bootstrap/api/internal/store"
)
// fakeReceiver simulates the new Sovereign's /api/v1/handover/tofu-archive
@ -211,6 +213,39 @@ func TestFinaliseHandover_FullFlow(t *testing.T) {
if _, err := os.Stat(kcPath); !os.IsNotExist(err) {
t.Errorf("kubeconfig not cleaned: %v", err)
}
// Slim-record contract (#453): the deployment must NOT be deleted.
// Audit-minimum + redirect-essentials must remain on the in-memory
// row so a follow-up GET /api/v1/deployments/{id} hands the UI the
// `adoptedAt` + `sovereignFQDN` it needs to fire the redirect from
// console.openova.io/sovereign/<id> → console.<sovereign-fqdn>.
val, ok := h.deployments.Load("dep-full")
if !ok {
t.Fatalf("deployment record was deleted post-handover; #453 contract requires slim retention")
}
got := val.(*Deployment)
if got.AdoptedAt == nil {
t.Errorf("AdoptedAt nil after FinaliseHandover; redirect contract needs non-nil timestamp")
}
if got.Request.SovereignFQDN != "tenant-y.omani.works" {
t.Errorf("SovereignFQDN dropped from slim record: %q", got.Request.SovereignFQDN)
}
if got.ID != "dep-full" {
t.Errorf("ID drifted on slim record: %q", got.ID)
}
// Operational fields must be zeroed.
if got.Result != nil {
t.Errorf("Result not zeroed on slim record: %+v", got.Result)
}
if got.Error != "" {
t.Errorf("Error not zeroed on slim record: %q", got.Error)
}
if got.Request.HetznerToken != "" || got.Request.DynadotAPIKey != "" {
t.Errorf("credential fields leaked into slim record: %+v", got.Request)
}
if got.Status != "adopted" {
t.Errorf("Status not transitioned to adopted: %q", got.Status)
}
}
func TestFinaliseHandover_ReceiverFailureKeepsLocalState(t *testing.T) {
@ -428,6 +463,261 @@ func keysOf[K comparable, V any](m map[K]V) []K {
return out
}
// TestFinaliseHandover_PreservesRedirectContract is the explicit guard
// against the regression #453 fixed: handover-finalisation MUST leave a
// reachable deployment row whose JSON shape carries `adoptedAt` +
// `sovereignFQDN`, because that is what the UI router's beforeLoad
// fetches to fire the post-handover redirect (issue #319 PR #451).
//
// We drive the same FinaliseHandover handler used in production, then
// hit GET /api/v1/deployments/{id} (the canonical handler the UI
// fetches) and assert the JSON response contains the redirect
// contract. Anything else (404, missing adoptedAt, missing
// sovereignFQDN) breaks the redirect.
func TestFinaliseHandover_PreservesRedirectContract(t *testing.T) {
dir := t.TempDir()
st, err := store.New(dir)
if err != nil {
t.Fatalf("store.New: %v", err)
}
h := newTestHandler(t)
h.store = st
dep := seedDeployment(t, h, "dep-redir", "tenant-r.omani.works")
dep.Request.OrgEmail = "ops@tenant-r.example"
dep.Request.OrgName = "Tenant R"
// Seed an initial Save so on-disk state exists pre-handover.
h.persistDeployment(dep)
// Wire a fake receiver so the Tofu archive POST succeeds.
recvr := &fakeReceiver{}
srv := httptest.NewServer(recvr.handler())
defer srv.Close()
h.SetHandoverTargetURL(srv.URL)
h.SetHandoverHTTPClient(srv.Client())
r := chi.NewRouter()
r.Post("/api/v1/handover/finalise/{id}", h.FinaliseHandover)
r.Get("/api/v1/deployments/{id}", h.GetDeployment)
// Drive the finalise.
req := httptest.NewRequest(http.MethodPost, "/api/v1/handover/finalise/dep-redir", strings.NewReader("{}"))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
r.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("finalise: status=%d body=%s", rec.Code, rec.Body.String())
}
// GET /api/v1/deployments/{id} — the path the UI router beforeLoad
// fetches. Per #319 PR #451 it expects {adoptedAt, sovereignFQDN}.
getReq := httptest.NewRequest(http.MethodGet, "/api/v1/deployments/dep-redir", nil)
getRec := httptest.NewRecorder()
r.ServeHTTP(getRec, getReq)
if getRec.Code != http.StatusOK {
t.Fatalf("GET deployment after handover: status=%d body=%s — record was deleted! redirect cannot fire", getRec.Code, getRec.Body.String())
}
var body map[string]any
if err := json.Unmarshal(getRec.Body.Bytes(), &body); err != nil {
t.Fatalf("decode: %v", err)
}
adoptedAt, ok := body["adoptedAt"].(string)
if !ok || adoptedAt == "" {
t.Fatalf("adoptedAt missing/empty in GET response: %v", body)
}
if _, err := time.Parse(time.RFC3339, adoptedAt); err != nil {
t.Errorf("adoptedAt not RFC3339: %q (%v)", adoptedAt, err)
}
if got, _ := body["sovereignFQDN"].(string); got != "tenant-r.omani.works" {
t.Errorf("sovereignFQDN missing or wrong: %v", body)
}
// On-disk record must round-trip through the store with AdoptedAt set.
rec2, err := st.Load("dep-redir")
if err != nil {
t.Fatalf("store.Load post-handover: %v — on-disk record missing breaks redirect after Pod restart", err)
}
if rec2.AdoptedAt == nil {
t.Errorf("on-disk record AdoptedAt nil")
}
if rec2.Request.SovereignFQDN != "tenant-r.omani.works" {
t.Errorf("on-disk record SovereignFQDN: %q", rec2.Request.SovereignFQDN)
}
// Operational fields must be absent on disk.
if rec2.Result != nil {
t.Errorf("on-disk Result not zeroed: %+v", rec2.Result)
}
if rec2.Request.HetznerToken != "" {
t.Errorf("on-disk credentials leaked: %q", rec2.Request.HetznerToken)
}
}
// TestSlimForHandover is a unit-level cover for the in-memory record
// transform invoked by FinaliseHandover. We assert all four classes of
// behaviour: audit fields kept, redirect field set, operational fields
// zeroed, channels closed.
func TestSlimForHandover(t *testing.T) {
finished := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
earlier := time.Date(2026, 4, 30, 10, 0, 0, 0, time.UTC)
cases := []struct {
name string
in *Deployment
}{
{
name: "full-record",
in: &Deployment{
ID: "dep-1",
Status: "ready",
StartedAt: earlier,
Request: provisioner.Request{
OrgName: "Acme",
OrgEmail: "ops@acme.io",
SovereignFQDN: "k8s.acme.io",
HetznerToken: "secret-do-not-leak",
DynadotAPIKey: "secret-too",
},
Result: &provisioner.Result{
LoadBalancerIP: "1.2.3.4",
KubeconfigPath: "/tmp/kc",
},
Error: "",
kubeconfigBearerHash: "deadbeef",
pdmReservationToken: "rt",
pdmPoolDomain: "omani.works",
pdmSubdomain: "acme",
eventsCh: make(chan provisioner.Event, 4),
done: make(chan struct{}),
},
},
{
name: "minimal-record",
in: &Deployment{
ID: "dep-2",
Status: "phase1-watching",
StartedAt: earlier,
Request: provisioner.Request{
SovereignFQDN: "byo.example.com",
},
eventsCh: make(chan provisioner.Event, 1),
done: make(chan struct{}),
},
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
out := tc.in.SlimForHandover(finished)
if out == nil {
t.Fatal("SlimForHandover returned nil")
}
// Audit-minimum kept.
if out.ID != tc.in.ID {
t.Errorf("ID dropped: in=%q out=%q", tc.in.ID, out.ID)
}
if out.Request.SovereignFQDN != tc.in.Request.SovereignFQDN {
t.Errorf("SovereignFQDN dropped: in=%q out=%q", tc.in.Request.SovereignFQDN, out.Request.SovereignFQDN)
}
if !out.StartedAt.Equal(tc.in.StartedAt) {
t.Errorf("StartedAt dropped: in=%v out=%v", tc.in.StartedAt, out.StartedAt)
}
if out.Request.OrgName != tc.in.Request.OrgName {
t.Errorf("OrgName dropped: in=%q out=%q", tc.in.Request.OrgName, out.Request.OrgName)
}
if out.Request.OrgEmail != tc.in.Request.OrgEmail {
t.Errorf("OrgEmail (createdBy) dropped: in=%q out=%q", tc.in.Request.OrgEmail, out.Request.OrgEmail)
}
// Redirect contract.
if out.AdoptedAt == nil {
t.Errorf("AdoptedAt nil")
} else if !out.AdoptedAt.Equal(finished) {
t.Errorf("AdoptedAt: got=%v want=%v", *out.AdoptedAt, finished)
}
// Status transitioned.
if out.Status != "adopted" {
t.Errorf("Status: got=%q want=adopted", out.Status)
}
// Operational fields zeroed.
if out.Result != nil {
t.Errorf("Result not zeroed: %+v", out.Result)
}
if out.Error != "" {
t.Errorf("Error not zeroed: %q", out.Error)
}
if out.Request.HetznerToken != "" {
t.Errorf("HetznerToken leaked: %q", out.Request.HetznerToken)
}
if out.Request.DynadotAPIKey != "" {
t.Errorf("DynadotAPIKey leaked: %q", out.Request.DynadotAPIKey)
}
if out.kubeconfigBearerHash != "" {
t.Errorf("kubeconfigBearerHash leaked: %q", out.kubeconfigBearerHash)
}
if out.pdmReservationToken != "" {
t.Errorf("pdmReservationToken leaked: %q", out.pdmReservationToken)
}
// Channels closed (any consumer terminates immediately).
select {
case _, open := <-out.eventsCh:
if open {
t.Errorf("eventsCh not closed")
}
default:
t.Errorf("eventsCh blocked (should be closed)")
}
select {
case <-out.done:
// closed — good
default:
t.Errorf("done not closed")
}
})
}
}
// TestSlimForHandover_StoreRecordRoundTrip verifies the slim record
// serialised through store.Record JSON and decoded back retains the
// AdoptedAt + SovereignFQDN contract. This is the cross-restart guard:
// after a Pod roll, the rehydrated row must still serve the redirect.
func TestSlimForHandover_StoreRecordRoundTrip(t *testing.T) {
finished := time.Date(2026, 5, 1, 12, 0, 0, 0, time.UTC)
original := &Deployment{
ID: "dep-rt",
Status: "ready",
StartedAt: time.Date(2026, 4, 28, 9, 0, 0, 0, time.UTC),
Request: provisioner.Request{
OrgName: "Tenant",
OrgEmail: "ops@tenant.io",
SovereignFQDN: "tenant.omani.works",
HetznerToken: "secret-must-not-survive",
},
}
slim := original.SlimForHandover(finished)
rec := slim.toRecord()
raw, err := json.Marshal(rec)
if err != nil {
t.Fatalf("marshal: %v", err)
}
var decoded store.Record
if err := json.Unmarshal(raw, &decoded); err != nil {
t.Fatalf("unmarshal: %v", err)
}
if decoded.AdoptedAt == nil || !decoded.AdoptedAt.Equal(finished) {
t.Errorf("AdoptedAt did not round-trip: %v", decoded.AdoptedAt)
}
if decoded.Request.SovereignFQDN != "tenant.omani.works" {
t.Errorf("SovereignFQDN did not round-trip: %q", decoded.Request.SovereignFQDN)
}
// Credentials must be redacted on disk (existing Redact contract).
if decoded.Request.HetznerToken != "" {
// Slim record sets HetznerToken to "" before redaction; redaction
// only marks present fields. So an empty token must round-trip
// as empty (not <redacted>).
t.Errorf("HetznerToken not zero on slim record after round-trip: %q", decoded.Request.HetznerToken)
}
}
// Ensure the helmwatch.Watcher.Cancel hook compiles against the existing
// internal/helmwatch package without surprising the linker.
var _ = context.Background