package registry import ( "context" "errors" "io" "os" "path/filepath" "testing" "time" ) func remoteTestRegistry(t *testing.T) (*Registry, string) { t.Helper() if os.Geteuid() == 0 { t.Skip("in-process transport fixture must not open service files as root; credential tests use a dropped child") } path := filepath.Join(t.TempDir(), "registry.db") r, err := OpenRemoteReconnect(path, func() (io.WriteCloser, io.ReadCloser, func() error, error) { requestR, requestW := io.Pipe() responseR, responseW := io.Pipe() done := make(chan error, 1) go func() { err := ServeRemote(path, requestR, responseW) requestR.Close() responseW.Close() done <- err }() return requestW, responseR, func() error { return <-done }, nil }) if err != nil { t.Fatal(err) } t.Cleanup(func() { r.Close() }) return r, path } func TestRemoteRegistryRoundTrip(t *testing.T) { r, _ := remoteTestRegistry(t) route := Route{Host: "remote.example.com", Kind: "instance", Status: "ready"} if err := r.Upsert(route); err != nil { t.Fatal(err) } if err := r.SetVersions(route.Host, "v2", "v1"); err != nil { t.Fatal(err) } got, err := r.Get(route.Host) if err != nil || got == nil || got.Version != "v2" || got.PrevVersion != "v1" { t.Fatalf("route = %+v, %v", got, err) } timer, err := r.UpsertTimerRegistration(Timer{Host: route.Host, Name: "tick", Kind: "cron", Spec: "* * * * *"}, "") if err != nil { t.Fatal(err) } saved, err := r.GetTimer(route.Host, "tick") if err != nil || saved == nil || saved.Registration == "" || saved.Registration != timer.Registration { t.Fatalf("timer = %+v, %v", saved, err) } if _, err := r.GetTimer(route.Host, "missing"); !errors.Is(err, os.ErrNotExist) { t.Fatalf("missing sentinel = %v", err) } if err := r.SetClaimHold(t.Context(), route.Host, ClaimOwnership{}, "", "op"); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("claim sentinel = %v", err) } if err := r.RetireClaimRows(t.Context(), "journal", nil, nil); !errors.Is(err, ErrClaimOwnership) { t.Fatalf("nil archive = %v", err) } } // The zero-downtime good-build and serving writers (plan 5) go through the // service-user child like every other write a root pass makes: without the // remote branch they would reach a nil *sql.DB. A lost expected-current // condition keeps its sentinel across the transport. func TestRemoteGoodBuildAndServingVersion(t *testing.T) { r, _ := remoteTestRegistry(t) host := "remote.example.com" if err := r.Upsert(Route{Host: host, Kind: "instance", Status: "ready"}); err != nil { t.Fatal(err) } next := GoodBuild{Version: "v2", Digest: "d2", Demand: "overlap"} if err := r.SetGoodBuild(host, GoodBuild{}, next); err != nil { t.Fatal(err) } if err := r.SetGoodBuild(host, GoodBuild{}, next); !errors.Is(err, ErrGoodBuildChanged) { t.Fatalf("stale expect = %v, want ErrGoodBuildChanged", err) } if err := r.SetServingVersion(host, "v2"); err != nil { t.Fatal(err) } got, err := r.Get(host) if err != nil || got == nil || got.Good() != next || got.ServingVersion != "v2" { t.Fatalf("route = %+v, %v", got, err) } } func TestRemoteHoldsFrozenKeepsWriteLock(t *testing.T) { r, path := remoteTestRegistry(t) if err := r.Upsert(Route{Host: "held", Kind: "instance"}); err != nil { t.Fatal(err) } local, err := Open(path) if err != nil { t.Fatal(err) } defer local.Close() finished := make(chan error, 1) callbackErr := errors.New("parent refused activation") err = r.WithHoldsFrozen(func(rows []Route) error { go func() { finished <- local.SetHold("held", "moving", "op") }() select { case err := <-finished: t.Fatalf("writer escaped frozen transaction: %v", err) case <-time.After(60 * time.Millisecond): } return callbackErr }) if err != callbackErr { t.Fatalf("callback error = %v", err) } select { case err := <-finished: if err != nil { t.Fatal(err) } case <-time.After(6 * time.Second): t.Fatal("writer remained locked after callback") } } func TestRemoteRetirementArchiveAndCancellation(t *testing.T) { for _, mode := range []string{"commit", "cancel", "deadline", "empty-error"} { t.Run(mode, func(t *testing.T) { cancelRequest := mode == "cancel" || mode == "deadline" callbackError := errors.New("") r, path := remoteTestRegistry(t) owner := ClaimOwnership{ClaimIncarnation: "inc", ClaimStamp: 1, ClaimOwner: "owner", ClaimRowID: "row"} if err := r.Upsert(Route{Host: "claim", Kind: "instance"}); err != nil { t.Fatal(err) } row, err := r.Get("claim") if err != nil { t.Fatal(err) } if err = r.AdoptClaimRoute(t.Context(), *row, owner); err != nil { t.Fatal(err) } ctx, cancel := context.WithCancel(t.Context()) if mode == "deadline" { cancel() ctx, cancel = context.WithTimeout(t.Context(), 100*time.Millisecond) } defer cancel() err = r.RetireClaimRows(ctx, "journal", []ClaimRow{{Kind: "route", Host: "claim", Ownership: owner}}, func(archives []ClaimRowArchive) error { if len(archives) != 1 || archives[0].Row.Ownership != owner { t.Fatalf("archive = %+v", archives) } if cancelRequest { if mode != "deadline" { cancel() } <-ctx.Done() } if mode == "empty-error" { return callbackError } return nil }) if mode == "empty-error" && err != callbackError { t.Fatalf("empty callback error = %v", err) } if mode == "deadline" && !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("deadline = %v", err) } if mode == "cancel" && !errors.Is(err, context.Canceled) { t.Fatalf("cancel = %v", err) } if mode == "commit" && err != nil { t.Fatal(err) } if _, err := r.Get("claim"); err != nil { t.Fatalf("call after %s: %v", mode, err) } local, err := Open(path) if err != nil { t.Fatal(err) } defer local.Close() current, err := local.Get("claim") if err != nil { t.Fatal(err) } if (mode != "commit") != (current != nil) { t.Fatalf("cancel=%v: remaining row = %+v", cancelRequest, current) } }) } } func TestRemoteCallbackPanicRollsBack(t *testing.T) { r, path := remoteTestRegistry(t) func() { defer func() { if recover() == nil { t.Error("callback did not panic") } }() r.WithHoldsFrozen(func([]Route) error { panic("parent panic") }) }() local, err := Open(path) if err != nil { t.Fatal(err) } defer local.Close() if err := local.Upsert(Route{Host: "after-panic", Kind: "instance"}); err != nil { t.Fatalf("lock remained held: %v", err) } } func TestRemoteReconnectAfterTransportDies(t *testing.T) { r, _ := remoteTestRegistry(t) if err := r.Upsert(Route{Host: "survives", Kind: "instance"}); err != nil { t.Fatal(err) } r.remote.output.Close() if _, err := r.Get("survives"); err == nil { t.Fatal("broken transport unexpectedly succeeded") } row, err := r.Get("survives") if err != nil || row == nil { t.Fatalf("reconnected read: %+v, %v", row, err) } if err := r.Close(); err != nil { t.Fatal(err) } if _, err := r.Get("survives"); !errors.Is(err, os.ErrClosed) { t.Fatalf("closed registry reopened: %v", err) } } func TestRemoteCancellationDuringReconnect(t *testing.T) { r, _ := remoteTestRegistry(t) connect := r.remote.connect if err := r.remote.disconnect(); err != nil { t.Fatal(err) } r.remote.connect = func() (io.WriteCloser, io.ReadCloser, func() error, error) { requestR, requestW := io.Pipe() responseR, responseW := io.Pipe() return requestW, responseR, func() error { requestR.Close() responseW.Close() return nil }, nil } ctx, cancel := context.WithTimeout(t.Context(), 20*time.Millisecond) defer cancel() err := r.SetClaimHold(ctx, "unused", ClaimOwnership{}, "", "") if !errors.Is(err, context.DeadlineExceeded) { t.Fatalf("startup cancellation = %v", err) } r.remote.connect = connect if _, err := r.Get("unused"); err != nil { t.Fatalf("call after startup cancellation: %v", err) } }