690 lines
24 KiB
Go
690 lines
24 KiB
Go
package cliproxy
|
|
|
|
import (
|
|
"bufio"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"io"
|
|
"net"
|
|
"strconv"
|
|
"strings"
|
|
"sync/atomic"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/config"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/home"
|
|
"github.com/router-for-me/CLIProxyAPI/v7/internal/homeplugins"
|
|
sdkpluginstore "github.com/router-for-me/CLIProxyAPI/v7/sdk/pluginstore"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
func TestSyncHomePluginsSkipsUnchangedSignature(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Plugins.Enabled = true
|
|
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
|
|
|
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
|
return sdkpluginstore.PluginSyncResponse{
|
|
SchemaVersion: sdkpluginstore.PluginSyncSchemaVersion,
|
|
ExpiresAt: time.Now().UTC().Add(time.Minute),
|
|
Items: []sdkpluginstore.PluginSyncItem{},
|
|
}, nil
|
|
}}
|
|
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
|
if errSync != nil {
|
|
t.Fatalf("syncHomePlugins() error = %v", errSync)
|
|
}
|
|
if !didSync || key == "" || !report.OK {
|
|
t.Fatalf("syncHomePlugins() didSync=%v key=%q report=%+v, want reportable empty plan", didSync, key, report)
|
|
}
|
|
service.markHomePluginsSynced(key)
|
|
|
|
_, gotKey, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
|
if errSync != nil {
|
|
t.Fatalf("syncHomePlugins(second) error = %v", errSync)
|
|
}
|
|
if didSync || gotKey != key {
|
|
t.Fatalf("syncHomePlugins(second) didSync=%v key=%q, want skipped same key %q", didSync, gotKey, key)
|
|
}
|
|
}
|
|
|
|
func TestSyncHomePluginsFetchFailureReturnsFailureReport(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Plugins.Enabled = true
|
|
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
|
wantErr := errors.New("plugin sync unavailable")
|
|
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
|
return sdkpluginstore.PluginSyncResponse{}, wantErr
|
|
}}
|
|
|
|
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
|
if !errors.Is(errSync, wantErr) {
|
|
t.Fatalf("syncHomePlugins() error = %v, want %v", errSync, wantErr)
|
|
}
|
|
if didSync {
|
|
t.Fatalf("syncHomePlugins() didSync = true, want false before a plan is available")
|
|
}
|
|
if key == "" {
|
|
t.Fatal("syncHomePlugins() key is empty")
|
|
}
|
|
if report.SchemaVersion != 1 || report.Task != "plugin-sync" || report.OK || report.Error != wantErr.Error() {
|
|
t.Fatalf("syncHomePlugins() report = %#v, want reportable fetch failure", report)
|
|
}
|
|
}
|
|
|
|
func TestSyncHomePluginsFallsBackForUnsupportedHomeProtocol(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Plugins.Enabled = true
|
|
cfg.Plugins.Dir = t.TempDir()
|
|
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
|
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
|
return sdkpluginstore.PluginSyncResponse{}, home.ErrPluginSyncUnsupported
|
|
}}
|
|
|
|
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
|
if errSync != nil {
|
|
t.Fatalf("syncHomePlugins() error = %v", errSync)
|
|
}
|
|
if !didSync || key == "" {
|
|
t.Fatalf("syncHomePlugins() didSync=%v key=%q, want legacy fallback", didSync, key)
|
|
}
|
|
if !report.OK || report.Task != "plugin-sync" {
|
|
t.Fatalf("syncHomePlugins() report = %#v, want successful legacy sync", report)
|
|
}
|
|
}
|
|
|
|
func TestSyncHomePluginsSkipsFetchWhenPluginsDisabled(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
|
fetchCalls := 0
|
|
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
|
fetchCalls++
|
|
return sdkpluginstore.PluginSyncResponse{}, errors.New("fetch should not be called")
|
|
}}
|
|
|
|
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
|
if errSync != nil {
|
|
t.Fatalf("syncHomePlugins() error = %v", errSync)
|
|
}
|
|
if didSync || fetchCalls != 0 {
|
|
t.Fatalf("syncHomePlugins() didSync=%v fetchCalls=%d, want disabled skip", didSync, fetchCalls)
|
|
}
|
|
if key == "" || report.Task != "plugin-sync" || !report.OK {
|
|
t.Fatalf("disabled sync key/report = %q/%#v, want reportable disabled status", key, report)
|
|
}
|
|
if service.homePluginSyncKey != "" {
|
|
t.Fatalf("homePluginSyncKey = %q, want caller to mark after reporting", service.homePluginSyncKey)
|
|
}
|
|
}
|
|
|
|
func TestSyncHomePluginsSkipsDisabledReportWhenUnchanged(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
|
service := &Service{homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
|
return sdkpluginstore.PluginSyncResponse{}, errors.New("fetch should not be called")
|
|
}}
|
|
|
|
report, key, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
|
if errSync != nil {
|
|
t.Fatalf("syncHomePlugins() error = %v", errSync)
|
|
}
|
|
if didSync || key == "" || report.Task != "plugin-sync" || !report.OK {
|
|
t.Fatalf("syncHomePlugins() didSync=%v key=%q report=%#v, want reportable disabled status", didSync, key, report)
|
|
}
|
|
service.markHomePluginsSynced(key)
|
|
|
|
report, gotKey, didSync, errSync := service.syncHomePlugins(context.Background(), cfg)
|
|
if errSync != nil {
|
|
t.Fatalf("syncHomePlugins(second) error = %v", errSync)
|
|
}
|
|
if didSync || gotKey != key || report.Task != "" {
|
|
t.Fatalf("syncHomePlugins(second) didSync=%v key=%q report=%#v, want skipped unchanged disabled status", didSync, gotKey, report)
|
|
}
|
|
}
|
|
|
|
func TestApplyHomeOverlayReturnsRuntimePluginSyncFailureWithoutApplyingConfig(t *testing.T) {
|
|
base := &config.Config{}
|
|
base.Home.Enabled = true
|
|
base.Plugins.Enabled = true
|
|
service := &Service{cfg: base}
|
|
|
|
enabled := true
|
|
remote := &config.Config{}
|
|
remote.Plugins.Enabled = true
|
|
remote.Plugins.Configs = map[string]config.PluginInstanceConfig{
|
|
"broken": {
|
|
Enabled: &enabled,
|
|
Raw: yaml.Node{
|
|
Kind: yaml.MappingNode,
|
|
Tag: "!!map",
|
|
Content: []*yaml.Node{
|
|
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "store"},
|
|
{
|
|
Kind: yaml.MappingNode,
|
|
Tag: "!!map",
|
|
Content: []*yaml.Node{
|
|
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "id"},
|
|
{Kind: yaml.ScalarNode, Tag: "!!str", Value: "broken"},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
if errApply := service.applyHomeOverlayContext(context.Background(), remote); errApply == nil {
|
|
t.Fatal("applyHomeOverlayContext() error = nil, want plugin sync failure")
|
|
}
|
|
if service.cfg == nil || !service.cfg.Home.Enabled || len(service.cfg.Plugins.Configs) != 0 {
|
|
t.Fatalf("service cfg = %+v, want unchanged config after plugin sync failure", service.cfg)
|
|
}
|
|
if service.homePluginSyncKey != "" {
|
|
t.Fatalf("homePluginSyncKey = %q, want empty after plugin sync failure", service.homePluginSyncKey)
|
|
}
|
|
}
|
|
|
|
func TestStartHomeSubscriberDoesNotPreMarkPluginSync(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.Host = "127.0.0.1"
|
|
cfg.Home.Port = 1
|
|
cfg.Plugins.Enabled = true
|
|
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
|
service := &Service{cfg: cfg}
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
service.startHomeSubscriber(ctx)
|
|
defer func() {
|
|
home.ClearCurrent()
|
|
if service.homeCancel != nil {
|
|
service.homeCancel()
|
|
}
|
|
if service.homeClient != nil {
|
|
service.homeClient.Close()
|
|
}
|
|
}()
|
|
|
|
if service.homePluginSyncKey != "" {
|
|
t.Fatalf("homePluginSyncKey = %q, want empty before a successful plugin sync", service.homePluginSyncKey)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeHomePluginWorkRetriesFailedStatusWithoutMarkingSynced(t *testing.T) {
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
var writes atomic.Int32
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go func(conn net.Conn) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
if _, errWrite := io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "RPUSH") && args[1] == "plugin-status":
|
|
if writes.Add(1) == 1 {
|
|
if _, errWrite := io.WriteString(conn, "-ERR blocked\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
continue
|
|
}
|
|
if _, errWrite := io.WriteString(conn, ":1\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
default:
|
|
if _, errWrite := io.WriteString(conn, "+OK\r\n"); errWrite != nil {
|
|
return
|
|
}
|
|
}
|
|
}
|
|
}(conn)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
})
|
|
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
client := home.New(config.HomeConfig{Enabled: true, Host: host, Port: port})
|
|
t.Cleanup(client.Close)
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.NodeID = "node-1"
|
|
service := &Service{}
|
|
work := &homePluginFinalization{
|
|
statusWork: []homePluginStatusWork{{cfg: cfg, report: homeplugins.CompletedSyncReport(homeplugins.CurrentPlatform(), nil)}},
|
|
syncKey: "sync-key",
|
|
markSynced: true,
|
|
}
|
|
if errFinalize := service.finalizeHomePluginWork(context.Background(), client, work); errFinalize == nil {
|
|
t.Fatal("first plugin status finalization succeeded, want Home rejection")
|
|
}
|
|
if service.homePluginSyncKey != "" || work.nextStatus != 0 || !work.markSynced {
|
|
t.Fatalf("failed finalization marked or advanced work: key=%q next=%d marked=%v", service.homePluginSyncKey, work.nextStatus, work.markSynced)
|
|
}
|
|
if errFinalize := service.finalizeHomePluginWork(context.Background(), client, work); errFinalize != nil {
|
|
t.Fatalf("retry finalization error = %v", errFinalize)
|
|
}
|
|
if service.homePluginSyncKey != "sync-key" || work.nextStatus != 1 || work.markSynced {
|
|
t.Fatalf("successful finalization state: key=%q next=%d marked=%v", service.homePluginSyncKey, work.nextStatus, work.markSynced)
|
|
}
|
|
if errFinalize := service.finalizeHomePluginWork(context.Background(), client, work); errFinalize != nil {
|
|
t.Fatalf("duplicate finalization error = %v", errFinalize)
|
|
}
|
|
if got := writes.Load(); got != 2 {
|
|
t.Fatalf("plugin status writes = %d, want one failed write and one successful retry", got)
|
|
}
|
|
}
|
|
|
|
func TestStageHomePluginTasksDefersDeleteUntilFinalization(t *testing.T) {
|
|
client, _ := newHomePluginTaskTestClient(t, []home.PluginTask{{ID: 7, Operation: "delete", PluginID: "plugin-a"}}, 0)
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.NodeID = "node-1"
|
|
var deletes atomic.Int32
|
|
service := &Service{homePluginDeleteTask: func(_ context.Context, _ *config.Config, task home.PluginTask) homeplugins.SyncReport {
|
|
deletes.Add(1)
|
|
return homeplugins.DeleteWithReport(context.Background(), nil, nil, task.ID, task.PluginID)
|
|
}}
|
|
|
|
taskWork, errStage := service.stageHomePluginTasksWithClient(context.Background(), cfg, client)
|
|
if errStage != nil {
|
|
t.Fatalf("stageHomePluginTasksWithClient() error = %v", errStage)
|
|
}
|
|
if got := deletes.Load(); got != 0 {
|
|
t.Fatalf("staged plugin deletes = %d, want 0 before controlled finalization", got)
|
|
}
|
|
if len(taskWork) != 1 || taskWork[0].task.ID != 7 {
|
|
t.Fatalf("staged task work = %#v, want delete task 7", taskWork)
|
|
}
|
|
|
|
if errFinalize := service.finalizeHomePluginWork(context.Background(), client, &homePluginFinalization{taskWork: taskWork}); errFinalize != nil {
|
|
t.Fatalf("finalizeHomePluginWork() error = %v", errFinalize)
|
|
}
|
|
if got := deletes.Load(); got != 1 {
|
|
t.Fatalf("finalized plugin deletes = %d, want 1", got)
|
|
}
|
|
}
|
|
|
|
func TestFinalizeHomePluginTaskStatusRetryDoesNotRepeatDelete(t *testing.T) {
|
|
client, writes := newHomePluginTaskTestClient(t, nil, 1)
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.NodeID = "node-1"
|
|
var deletes atomic.Int32
|
|
service := &Service{homePluginDeleteTask: func(_ context.Context, _ *config.Config, task home.PluginTask) homeplugins.SyncReport {
|
|
deletes.Add(1)
|
|
return homeplugins.DeleteWithReport(context.Background(), nil, nil, task.ID, task.PluginID)
|
|
}}
|
|
work := &homePluginFinalization{taskWork: []homePluginTaskWork{{cfg: cfg, task: home.PluginTask{ID: 8, Operation: "delete", PluginID: "plugin-b"}}}}
|
|
|
|
if errFinalize := service.finalizeHomePluginWork(context.Background(), client, work); errFinalize == nil {
|
|
t.Fatal("first task report finalization succeeded, want Home rejection")
|
|
}
|
|
if got := deletes.Load(); got != 1 {
|
|
t.Fatalf("first finalization deletes = %d, want 1", got)
|
|
}
|
|
if work.nextTask != 0 || work.taskWork[0].report == nil {
|
|
t.Fatalf("failed task status did not retain action result: next=%d report=%#v", work.nextTask, work.taskWork[0].report)
|
|
}
|
|
if errFinalize := service.finalizeHomePluginWork(context.Background(), client, work); errFinalize != nil {
|
|
t.Fatalf("retry finalization error = %v", errFinalize)
|
|
}
|
|
if got := deletes.Load(); got != 1 {
|
|
t.Fatalf("retried finalization deletes = %d, want 1", got)
|
|
}
|
|
if work.nextTask != 1 {
|
|
t.Fatalf("task finalization next = %d, want 1", work.nextTask)
|
|
}
|
|
if gotWrites := writes.Load(); gotWrites != 2 {
|
|
t.Fatalf("task status writes = %d, want 2", gotWrites)
|
|
}
|
|
}
|
|
|
|
func newHomePluginTaskTestClient(t *testing.T, tasks []home.PluginTask, failStatuses int32) (*home.Client, *atomic.Int32) {
|
|
t.Helper()
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
rawTasks, errMarshal := json.Marshal(tasks)
|
|
if errMarshal != nil {
|
|
t.Fatalf("marshal tasks: %v", errMarshal)
|
|
}
|
|
var writes atomic.Int32
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go func(conn net.Conn) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
_, _ = io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n")
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "GET") && args[1] == "plugin-tasks":
|
|
_, _ = io.WriteString(conn, "$"+strconv.Itoa(len(rawTasks))+"\r\n")
|
|
_, _ = conn.Write(rawTasks)
|
|
_, _ = io.WriteString(conn, "\r\n")
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "RPUSH") && args[1] == "plugin-status":
|
|
if writes.Add(1) <= failStatuses {
|
|
_, _ = io.WriteString(conn, "-ERR blocked\r\n")
|
|
continue
|
|
}
|
|
_, _ = io.WriteString(conn, ":1\r\n")
|
|
default:
|
|
_, _ = io.WriteString(conn, "+OK\r\n")
|
|
}
|
|
}
|
|
}(conn)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
})
|
|
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
client := home.New(config.HomeConfig{Enabled: true, Host: host, Port: port})
|
|
t.Cleanup(client.Close)
|
|
return client, &writes
|
|
}
|
|
|
|
func TestStageHomeOverlayDoesNotApplyConfigAfterStageFailure(t *testing.T) {
|
|
baseCfg := &config.Config{}
|
|
baseCfg.Home.Enabled = true
|
|
baseCfg.Routing.Strategy = "round-robin"
|
|
remoteCfg := &config.Config{}
|
|
remoteCfg.Home.Enabled = true
|
|
remoteCfg.Routing.Strategy = "fill-first"
|
|
remoteCfg.Plugins.Enabled = true
|
|
service := &Service{
|
|
cfg: baseCfg,
|
|
homePluginSyncFetch: func(context.Context, sdkpluginstore.PluginSyncRequest) (sdkpluginstore.PluginSyncResponse, error) {
|
|
return sdkpluginstore.PluginSyncResponse{}, errors.New("plugin sync unavailable")
|
|
},
|
|
}
|
|
|
|
if _, errStage := service.stageHomeOverlayWithClient(context.Background(), remoteCfg, nil); errStage == nil {
|
|
t.Fatal("stageHomeOverlayWithClient() error = nil, want plugin sync failure")
|
|
}
|
|
|
|
service.cfgMu.RLock()
|
|
strategy := service.cfg.Routing.Strategy
|
|
service.cfgMu.RUnlock()
|
|
if strategy != "round-robin" {
|
|
t.Fatalf("failed stage applied routing strategy %q", strategy)
|
|
}
|
|
}
|
|
|
|
func TestReadyHomePluginFinalizationRetriesUntilStatusSucceeds(t *testing.T) {
|
|
client, writes := newHomePluginTaskTestClient(t, nil, 1)
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.NodeID = "node-1"
|
|
service := &Service{homeGeneration: 1}
|
|
work := &homePluginFinalization{
|
|
statusWork: []homePluginStatusWork{{cfg: cfg, report: homeplugins.CompletedSyncReport(homeplugins.CurrentPlatform(), nil)}},
|
|
syncKey: "sync-key",
|
|
markSynced: true,
|
|
}
|
|
|
|
if errFinalize := service.finalizeHomePluginWorkUntilDone(context.Background(), context.Background(), 1, client, work, nil); errFinalize != nil {
|
|
t.Fatalf("finalizeHomePluginWorkUntilDone() error = %v", errFinalize)
|
|
}
|
|
if gotWrites := writes.Load(); gotWrites != 2 {
|
|
t.Fatalf("plugin status writes = %d, want 2 after retry", gotWrites)
|
|
}
|
|
if service.homePluginSyncKey != "sync-key" || work.nextStatus != 1 || work.markSynced {
|
|
t.Fatalf("retried ready finalization state: key=%q next=%d marked=%v", service.homePluginSyncKey, work.nextStatus, work.markSynced)
|
|
}
|
|
}
|
|
|
|
func TestReplacementWaitsForHomePluginFinalizationOwnership(t *testing.T) {
|
|
client, statusStarted, releaseStatus := newBlockingHomePluginStatusClient(t)
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.NodeID = "node-1"
|
|
parentCtx, cancelParent := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelParent)
|
|
homeCtx, cancelHome := context.WithCancel(parentCtx)
|
|
t.Cleanup(cancelHome)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(homeCtx)
|
|
t.Cleanup(cancelLifetime)
|
|
previousDone := make(chan struct{})
|
|
cancelled := make(chan struct{})
|
|
service := &Service{
|
|
cfg: cfg,
|
|
homeGeneration: 1,
|
|
homeSupervisor: &homeSubscriberSupervisor{cancel: func() {
|
|
cancelLifetime()
|
|
close(cancelled)
|
|
close(previousDone)
|
|
}, done: previousDone},
|
|
}
|
|
work := &homePluginFinalization{statusWork: []homePluginStatusWork{{cfg: cfg, report: homeplugins.CompletedSyncReport(homeplugins.CurrentPlatform(), nil)}}}
|
|
finalized := make(chan error, 1)
|
|
go func() {
|
|
finalized <- service.finalizeHomePluginWorkUntilDone(lifetimeCtx, homeCtx, 1, client, work, func() bool { return true })
|
|
}()
|
|
select {
|
|
case <-statusStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("plugin status finalization did not start")
|
|
}
|
|
|
|
replacementReturned := make(chan struct{})
|
|
go func() {
|
|
service.startHomeSubscriber(parentCtx)
|
|
close(replacementReturned)
|
|
}()
|
|
select {
|
|
case <-cancelled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement did not cancel the blocked controlled finalization")
|
|
}
|
|
select {
|
|
case errFinalize := <-finalized:
|
|
if !errors.Is(errFinalize, context.Canceled) {
|
|
t.Fatalf("finalization error = %v, want context cancellation", errFinalize)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("blocked finalization did not exit after replacement cancellation")
|
|
}
|
|
|
|
close(releaseStatus)
|
|
cancelParent()
|
|
select {
|
|
case <-replacementReturned:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("replacement did not return after cancellation")
|
|
}
|
|
}
|
|
|
|
func TestShutdownCancelsBlockedHomePluginFinalization(t *testing.T) {
|
|
client, statusStarted, releaseStatus := newBlockingHomePluginStatusClient(t)
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Home.NodeID = "node-1"
|
|
parentCtx, cancelParent := context.WithCancel(context.Background())
|
|
t.Cleanup(cancelParent)
|
|
homeCtx, cancelHome := context.WithCancel(parentCtx)
|
|
t.Cleanup(cancelHome)
|
|
lifetimeCtx, cancelLifetime := context.WithCancel(homeCtx)
|
|
t.Cleanup(cancelLifetime)
|
|
previousDone := make(chan struct{})
|
|
cancelled := make(chan struct{})
|
|
service := &Service{
|
|
cfg: cfg,
|
|
homeGeneration: 1,
|
|
homeSupervisor: &homeSubscriberSupervisor{cancel: func() {
|
|
cancelLifetime()
|
|
close(cancelled)
|
|
close(previousDone)
|
|
}, done: previousDone},
|
|
}
|
|
work := &homePluginFinalization{statusWork: []homePluginStatusWork{{cfg: cfg, report: homeplugins.CompletedSyncReport(homeplugins.CurrentPlatform(), nil)}}}
|
|
finalized := make(chan error, 1)
|
|
go func() {
|
|
finalized <- service.finalizeHomePluginWorkUntilDone(lifetimeCtx, homeCtx, 1, client, work, nil)
|
|
}()
|
|
select {
|
|
case <-statusStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("plugin status finalization did not start")
|
|
}
|
|
|
|
shutdownDone := make(chan error, 1)
|
|
go func() {
|
|
shutdownDone <- service.Shutdown(context.Background())
|
|
}()
|
|
select {
|
|
case <-cancelled:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("shutdown did not cancel the blocked controlled finalization")
|
|
}
|
|
select {
|
|
case errFinalize := <-finalized:
|
|
if !errors.Is(errFinalize, context.Canceled) {
|
|
t.Fatalf("finalization error = %v, want context cancellation", errFinalize)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("blocked finalization did not exit after shutdown cancellation")
|
|
}
|
|
|
|
close(releaseStatus)
|
|
select {
|
|
case errShutdown := <-shutdownDone:
|
|
if errShutdown != nil {
|
|
t.Fatalf("Shutdown() error = %v", errShutdown)
|
|
}
|
|
case <-time.After(time.Second):
|
|
t.Fatal("shutdown did not return after finalization cancellation")
|
|
}
|
|
}
|
|
|
|
func newBlockingHomePluginStatusClient(t *testing.T) (*home.Client, <-chan struct{}, chan<- struct{}) {
|
|
t.Helper()
|
|
listener, errListen := net.Listen("tcp", "127.0.0.1:0")
|
|
if errListen != nil {
|
|
t.Fatalf("listen: %v", errListen)
|
|
}
|
|
statusStarted := make(chan struct{})
|
|
releaseStatus := make(chan struct{})
|
|
serverDone := make(chan struct{})
|
|
go func() {
|
|
defer close(serverDone)
|
|
for {
|
|
conn, errAccept := listener.Accept()
|
|
if errAccept != nil {
|
|
return
|
|
}
|
|
go func(conn net.Conn) {
|
|
defer func() { _ = conn.Close() }()
|
|
reader := bufio.NewReader(conn)
|
|
for {
|
|
args, errRead := readRegistryTestRedisCommand(reader)
|
|
if errRead != nil {
|
|
return
|
|
}
|
|
switch {
|
|
case len(args) > 0 && strings.EqualFold(args[0], "HELLO"):
|
|
_, _ = io.WriteString(conn, "%6\r\n$6\r\nserver\r\n$5\r\nredis\r\n$5\r\nproto\r\n:3\r\n$2\r\nid\r\n:1\r\n$4\r\nmode\r\n$10\r\nstandalone\r\n$4\r\nrole\r\n$6\r\nmaster\r\n$7\r\nmodules\r\n*0\r\n")
|
|
case len(args) >= 2 && strings.EqualFold(args[0], "RPUSH") && args[1] == "plugin-status":
|
|
close(statusStarted)
|
|
<-releaseStatus
|
|
_, _ = io.WriteString(conn, ":1\r\n")
|
|
default:
|
|
_, _ = io.WriteString(conn, "+OK\r\n")
|
|
}
|
|
}
|
|
}(conn)
|
|
}
|
|
}()
|
|
t.Cleanup(func() {
|
|
_ = listener.Close()
|
|
<-serverDone
|
|
})
|
|
host, portText, errSplit := net.SplitHostPort(listener.Addr().String())
|
|
if errSplit != nil {
|
|
t.Fatalf("split listener address: %v", errSplit)
|
|
}
|
|
port, errPort := strconv.Atoi(portText)
|
|
if errPort != nil {
|
|
t.Fatalf("parse port: %v", errPort)
|
|
}
|
|
client := home.New(config.HomeConfig{Enabled: true, Host: host, Port: port})
|
|
t.Cleanup(client.Close)
|
|
return client, statusStarted, releaseStatus
|
|
}
|
|
|
|
func TestHomePluginSyncKeyIncludesCredentialRevision(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.Home.Enabled = true
|
|
cfg.Plugins.Enabled = true
|
|
cfg.Plugins.Configs = map[string]config.PluginInstanceConfig{}
|
|
first := homePluginSyncKey(cfg)
|
|
cfg.Plugins.AuthRevision = 2
|
|
second := homePluginSyncKey(cfg)
|
|
if first == second {
|
|
t.Fatalf("homePluginSyncKey() unchanged after sync revision update: %q", first)
|
|
}
|
|
}
|
|
|
|
func TestForceHomeRuntimeConfigClearsStoreAuth(t *testing.T) {
|
|
cfg := &config.Config{}
|
|
cfg.Plugins.StoreAuth = []sdkpluginstore.AuthConfig{{
|
|
Match: "https://downloads.example/", Type: sdkpluginstore.AuthTypeBearer, TokenEnv: "PLUGIN_TOKEN",
|
|
}}
|
|
forceHomeRuntimeConfig(cfg)
|
|
if cfg.Plugins.StoreAuth != nil {
|
|
t.Fatalf("Plugins.StoreAuth = %#v, want nil in Home mode", cfg.Plugins.StoreAuth)
|
|
}
|
|
}
|