mirror of
https://github.com/lukaszraczylo/kportal.git
synced 2026-10-17 04:57:58 +00:00
feat: add --context to forward only selected contexts (#85)
* feat: add --context to forward only selected contexts
kportal forwards every context in the config file. When the file describes
several clusters that host the same services, that means holding connections
open to clusters nobody is using, and - because all contexts are active at once
- every localPort has to be globally unique. The same service therefore lands on
a different local port per cluster, so switching clusters means reconfiguring
the app being developed.
--context limits a run to the contexts you name. It is repeatable and accepts
comma-separated values, and composes with -headless:
kportal --context development-team-a
kportal --context team-a,team-b -headless
kportal --context team-a --context team-b
An unknown name is an error listing the contexts the file defines, so a typo
fails loudly instead of silently forwarding nothing.
Local-port and mDNS-hostname conflicts are now scoped to forwards that could run
at the same time: same context, or both contexts in the active selection. With
no selection every context is active, so existing behaviour is unchanged.
Contexts that are never selected together may reuse the same localPort, which is
what lets a service keep one fixed local port in every cluster.
Validation still covers the whole file, so a problem in a context outside the
selection is still reported and --check does not get weaker. Loading, validating
and filtering are shared by every path that re-reads the config (startup, file
watcher, SIGHUP in both headless and verbose modes), so no path can forget the
selection.
Also fixes a latent identity bug this makes reachable: Forward.ID() is only
"alias:localPort" when an alias is set, which was unique across contexts only
because duplicate local ports were globally rejected. With ports now shareable,
deleting or editing a forward from the TUI could have matched same-alias
forwards in other contexts and silently removed them. Delete and edit are now
scoped to the context the row came from.
* fix(config): keep the running config when no selected context remains
A reload dropped selected contexts that no longer exist in the file.
When all of them vanished, the reload returned an empty config and the
manager stopped every forward after a single rename.
LoadForRuntime now returns an error in that case. The watcher keeps the
previous config, as it does for any other invalid reload.
Refs #80
* fix(ui): apply new table columns after a successful SIGHUP reload
The context selection moved the SIGHUP reload into reloadFromSignal,
which had no way to update the verbose table columns. The function now
takes a callback that runs only after the manager reloads without error.
The verbose table passes SetColumns.
Refs #80
* style: gofmt run_test.go
---------
Co-authored-by: André Cruz <[email protected]>
This commit is contained in:
+81
-43
@@ -51,6 +51,7 @@ type runOptions struct {
|
||||
logFormat string
|
||||
convertInput string
|
||||
convertOutput string
|
||||
contexts []string
|
||||
verbose bool
|
||||
headless bool
|
||||
check bool
|
||||
@@ -58,6 +59,28 @@ type runOptions struct {
|
||||
checkUpdate bool
|
||||
}
|
||||
|
||||
// contextListFlag collects repeated -context values. Each value may itself be a
|
||||
// comma-separated list, so "-context a -context b" and "-context a,b" are
|
||||
// equivalent.
|
||||
type contextListFlag []string
|
||||
|
||||
func (c *contextListFlag) String() string {
|
||||
if c == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.Join(*c, ",")
|
||||
}
|
||||
|
||||
func (c *contextListFlag) Set(value string) error {
|
||||
// Reject an empty value rather than silently forwarding every context -
|
||||
// "-context=$UNSET_VAR" in a script should fail loudly.
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return fmt.Errorf("context name cannot be empty")
|
||||
}
|
||||
*c = append(*c, value)
|
||||
return nil
|
||||
}
|
||||
|
||||
// fprintf is a small wrapper that suppresses the io.Writer write error. We
|
||||
// route output to caller-provided writers (stdout / stderr / io.Discard /
|
||||
// bytes.Buffer in tests), and a write error on any of these is non-actionable
|
||||
@@ -166,13 +189,31 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
|
||||
return code
|
||||
}
|
||||
|
||||
// Validate configuration (allow empty for newly created files).
|
||||
// Validate the whole configuration - including contexts that -context
|
||||
// excludes - so problems anywhere in the file are still reported. Only the
|
||||
// checks that depend on what actually runs (local ports, mDNS hostnames)
|
||||
// are scoped to the selection.
|
||||
validator := config.NewValidator()
|
||||
if errs := validator.ValidateConfigWithOptions(cfg, configIsNew || cfg.IsEmpty()); len(errs) > 0 {
|
||||
if errs := validator.ValidateConfigWithOpts(cfg, config.ValidateOptions{
|
||||
AllowEmpty: configIsNew || cfg.IsEmpty(),
|
||||
ActiveContexts: opts.contexts,
|
||||
}); len(errs) > 0 {
|
||||
fprint(stderr, config.FormatValidationErrors(errs))
|
||||
return 1
|
||||
}
|
||||
|
||||
// Narrow the config to the selected contexts for everything downstream.
|
||||
// Skipped for a config with no contexts at all (a file just created above),
|
||||
// where reporting an unknown context would be unhelpful.
|
||||
if len(opts.contexts) > 0 && len(cfg.Contexts) > 0 {
|
||||
selected, err := cfg.SelectContexts(opts.contexts)
|
||||
if err != nil {
|
||||
fprintf(stderr, "Error: %v\n", err)
|
||||
return 1
|
||||
}
|
||||
cfg = selected
|
||||
}
|
||||
|
||||
if opts.check {
|
||||
fprintln(stdout, "Configuration is valid")
|
||||
return 0
|
||||
@@ -192,9 +233,9 @@ func run(ctx context.Context, args []string, stdin io.Reader, stdout, stderr io.
|
||||
|
||||
switch {
|
||||
case opts.headless:
|
||||
return runHeadless(ctx, opts, cfg, deps, validator, stderr)
|
||||
return runHeadless(ctx, opts, cfg, deps, stderr)
|
||||
case opts.verbose:
|
||||
return runVerboseTable(ctx, opts, cfg, deps, validator, stderr)
|
||||
return runVerboseTable(ctx, opts, cfg, deps, stderr)
|
||||
default:
|
||||
return runInteractive(ctx, opts, cfg, deps, stderr)
|
||||
}
|
||||
@@ -208,6 +249,8 @@ func parseFlags(args []string, stderr io.Writer) (runOptions, int, bool) {
|
||||
fs.SetOutput(stderr)
|
||||
|
||||
var opts runOptions
|
||||
var contexts contextListFlag
|
||||
fs.Var(&contexts, "context", "Only forward the named contexts from the config file (repeatable and comma-separated; default: all)")
|
||||
fs.StringVar(&opts.configFile, "c", defaultConfigFile, "Path to configuration file")
|
||||
fs.BoolVar(&opts.verbose, "v", false, "Enable verbose logging")
|
||||
fs.BoolVar(&opts.headless, "headless", false, "Run in headless mode (no UI, for background/daemon use)")
|
||||
@@ -224,6 +267,7 @@ func parseFlags(args []string, stderr io.Writer) (runOptions, int, bool) {
|
||||
}
|
||||
return opts, 2, true
|
||||
}
|
||||
opts.contexts = config.NormalizeContextSelection(contexts)
|
||||
return opts, 0, false
|
||||
}
|
||||
|
||||
@@ -335,6 +379,30 @@ func loadOrCreateConfig(configFile string, stdin io.Reader, stdout, stderr io.Wr
|
||||
return cfg, true, 0, false
|
||||
}
|
||||
|
||||
// reloadFromSignal re-reads the config after SIGHUP and applies it to the
|
||||
// manager. Load, validation and the -context selection all live in
|
||||
// config.LoadForRuntime, shared with the file watcher, so no reload path can
|
||||
// forget the selection. Failures are logged and the previous config stays
|
||||
// active. onApplied, when non-nil, runs only after a successful Reload.
|
||||
func reloadFromSignal(opts runOptions, manager *forward.Manager, logIt bool, onApplied func(*config.Config)) {
|
||||
newCfg, err := config.LoadForRuntime(opts.configFile, opts.contexts)
|
||||
if err != nil {
|
||||
if logIt {
|
||||
log.Printf("Failed to reload config: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err := manager.Reload(newCfg); err != nil {
|
||||
if logIt {
|
||||
log.Printf("Failed to reload: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if onApplied != nil {
|
||||
onApplied(newCfg)
|
||||
}
|
||||
}
|
||||
|
||||
// runtimeDeps bundles the long-lived objects shared by all UI modes.
|
||||
type runtimeDeps struct {
|
||||
manager *forward.Manager
|
||||
@@ -355,6 +423,7 @@ func buildRuntimeDeps(opts runOptions, cfg *config.Config, stderr io.Writer) (*r
|
||||
}
|
||||
discovery := k8s.NewDiscovery(pool)
|
||||
mutator := config.NewMutator(opts.configFile)
|
||||
mutator.SetActiveContexts(opts.contexts)
|
||||
|
||||
manager, err := forward.NewManager(opts.verbose)
|
||||
if err != nil {
|
||||
@@ -429,7 +498,7 @@ func runConvert(input, output string, stdout, stderr io.Writer) int {
|
||||
|
||||
// runHeadless runs the daemon-style mode: no UI, signal-driven SIGHUP reloads,
|
||||
// graceful shutdown on ctx.Done() (which is cancelled by SIGINT/SIGTERM).
|
||||
func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, validator *config.Validator, stderr io.Writer) int {
|
||||
func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, stderr io.Writer) int {
|
||||
if startErr := deps.manager.Start(cfg); startErr != nil {
|
||||
fprintf(stderr, "Error starting forwards: %v\n", startErr)
|
||||
return 1
|
||||
@@ -442,7 +511,7 @@ func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps
|
||||
|
||||
watcher, watcherErr := config.NewWatcher(opts.configFile, func(newCfg *config.Config) error {
|
||||
return deps.manager.Reload(newCfg)
|
||||
}, opts.verbose)
|
||||
}, opts.verbose, config.WithContextSelection(opts.contexts))
|
||||
watcherStarted := false
|
||||
if watcherErr != nil {
|
||||
if opts.verbose {
|
||||
@@ -471,32 +540,14 @@ func runHeadless(ctx context.Context, opts runOptions, cfg *config.Config, deps
|
||||
if opts.verbose {
|
||||
log.Printf("Received SIGHUP, reloading configuration...")
|
||||
}
|
||||
newCfg, loadErr := config.LoadConfig(opts.configFile)
|
||||
if loadErr != nil {
|
||||
if opts.verbose {
|
||||
log.Printf("Failed to reload config: %v", loadErr)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
|
||||
if opts.verbose {
|
||||
log.Printf("Config validation failed:")
|
||||
log.Print(config.FormatValidationErrors(errs))
|
||||
}
|
||||
continue
|
||||
}
|
||||
if reloadErr := deps.manager.Reload(newCfg); reloadErr != nil {
|
||||
if opts.verbose {
|
||||
log.Printf("Failed to reload: %v", reloadErr)
|
||||
}
|
||||
}
|
||||
reloadFromSignal(opts, deps.manager, opts.verbose, nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runVerboseTable runs the simple table UI with periodic redraws and SIGHUP
|
||||
// reload, exiting cleanly when ctx is cancelled.
|
||||
func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, validator *config.Validator, stderr io.Writer) int {
|
||||
func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, deps *runtimeDeps, stderr io.Writer) int {
|
||||
tableUI := ui.NewTableUI(opts.verbose, cfg)
|
||||
deps.manager.SetStatusUI(tableUI)
|
||||
|
||||
@@ -544,7 +595,7 @@ func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, d
|
||||
}
|
||||
tableUI.SetColumns(newCfg)
|
||||
return nil
|
||||
}, opts.verbose)
|
||||
}, opts.verbose, config.WithContextSelection(opts.contexts))
|
||||
watcherActive := false
|
||||
if watchErr != nil {
|
||||
log.Printf("Warning: Failed to setup config watcher: %v", watchErr)
|
||||
@@ -568,21 +619,7 @@ func runVerboseTable(ctx context.Context, opts runOptions, cfg *config.Config, d
|
||||
return shutdownManager(ctx, deps.manager, opts.verbose)
|
||||
case <-sigChan:
|
||||
log.Printf("Received SIGHUP, reloading configuration...")
|
||||
newCfg, loadErr := config.LoadConfig(opts.configFile)
|
||||
if loadErr != nil {
|
||||
log.Printf("Failed to reload config: %v", loadErr)
|
||||
continue
|
||||
}
|
||||
if errs := validator.ValidateConfig(newCfg); len(errs) > 0 {
|
||||
log.Printf("Config validation failed:")
|
||||
log.Print(config.FormatValidationErrors(errs))
|
||||
continue
|
||||
}
|
||||
if reloadErr := deps.manager.Reload(newCfg); reloadErr != nil {
|
||||
log.Printf("Failed to reload: %v", reloadErr)
|
||||
continue
|
||||
}
|
||||
tableUI.SetColumns(newCfg)
|
||||
reloadFromSignal(opts, deps.manager, true, tableUI.SetColumns)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -598,6 +635,7 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de
|
||||
}, appVersion)
|
||||
bubbleTeaUI.SetColumns(cfg)
|
||||
bubbleTeaUI.SetWizardDependencies(deps.discovery, deps.mutator, opts.configFile)
|
||||
bubbleTeaUI.SetActiveContexts(opts.contexts)
|
||||
bubbleTeaUI.SetHTTPLogSubscriber(makeHTTPLogSubscriber(deps.manager))
|
||||
|
||||
go func() {
|
||||
@@ -623,7 +661,7 @@ func runInteractive(ctx context.Context, opts runOptions, cfg *config.Config, de
|
||||
}
|
||||
bubbleTeaUI.SetColumns(newCfg)
|
||||
return nil
|
||||
}, opts.verbose)
|
||||
}, opts.verbose, config.WithContextSelection(opts.contexts))
|
||||
if err == nil {
|
||||
watcher.Start()
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/lukaszraczylo/kportal/internal/config"
|
||||
"github.com/lukaszraczylo/kportal/internal/forward"
|
||||
"github.com/lukaszraczylo/kportal/internal/ui"
|
||||
"github.com/lukaszraczylo/kportal/internal/version"
|
||||
@@ -545,6 +546,7 @@ func TestParseFlags_Defaults(t *testing.T) {
|
||||
assert.False(t, opts.verbose)
|
||||
assert.False(t, opts.headless)
|
||||
assert.Equal(t, "text", opts.logFormat)
|
||||
assert.Empty(t, opts.contexts, "no -context means every context is forwarded")
|
||||
}
|
||||
|
||||
func TestParseFlags_AllSet(t *testing.T) {
|
||||
@@ -651,3 +653,190 @@ func TestLoadOrCreateConfig_NotFound_AcceptCreates(t *testing.T) {
|
||||
require.NotNil(t, cfg)
|
||||
assert.FileExists(t, cfgPath)
|
||||
}
|
||||
|
||||
// ---- -context selection ----
|
||||
|
||||
func TestParseFlags_ContextSelection(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
want []string
|
||||
}{
|
||||
{name: "single", args: []string{"-context", "team-a"}, want: []string{"team-a"}},
|
||||
{name: "repeated", args: []string{"-context", "team-a", "-context", "team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "comma separated", args: []string{"-context", "team-a,team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "equals form", args: []string{"-context=team-a,team-b"}, want: []string{"team-a", "team-b"}},
|
||||
{name: "double dash", args: []string{"--context", "team-a"}, want: []string{"team-a"}},
|
||||
{
|
||||
name: "repeated and comma mixed, de-duplicated",
|
||||
args: []string{"-context", "team-a,team-b", "-context", "team-a", "-context", " team-c "},
|
||||
want: []string{"team-a", "team-b", "team-c"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
opts, code, handled := parseFlags(tt.args, &stderr)
|
||||
require.False(t, handled)
|
||||
assert.Equal(t, 0, code)
|
||||
assert.Equal(t, tt.want, opts.contexts)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestParseFlags_EmptyContextRejected keeps "-context=$UNSET_VAR" in a script
|
||||
// from silently degrading to "forward everything".
|
||||
func TestParseFlags_EmptyContextRejected(t *testing.T) {
|
||||
var stderr bytes.Buffer
|
||||
_, code, handled := parseFlags([]string{"-context="}, &stderr)
|
||||
assert.True(t, handled)
|
||||
assert.Equal(t, 2, code)
|
||||
}
|
||||
|
||||
// sharedPortConfigBody is the layout from issue #80: the same service on the
|
||||
// same local port in two per-team clusters.
|
||||
const sharedPortConfigBody = `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: rate-service
|
||||
forwards:
|
||||
- resource: pod/rpc-server
|
||||
protocol: tcp
|
||||
port: 50051
|
||||
localPort: 3004
|
||||
`
|
||||
|
||||
func writeSharedPortConfig(t *testing.T) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
require.NoError(t, os.WriteFile(path, []byte(sharedPortConfigBody), 0600))
|
||||
return path
|
||||
}
|
||||
|
||||
// TestRun_CheckWithContextSelection is the end-to-end proof of issue #80:
|
||||
// selecting a single context makes a config that reuses local ports valid.
|
||||
func TestRun_CheckWithContextSelection(t *testing.T) {
|
||||
path := writeSharedPortConfig(t)
|
||||
|
||||
t.Run("without selection the shared port is a conflict", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "Duplicate local port 3004")
|
||||
})
|
||||
|
||||
t.Run("selecting one context is valid", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code, "stderr: %s", stderr.String())
|
||||
assert.Contains(t, stdout.String(), "Configuration is valid")
|
||||
})
|
||||
|
||||
t.Run("selecting both brings the conflict back", func(t *testing.T) {
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a,team-b"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "Duplicate local port 3004")
|
||||
})
|
||||
}
|
||||
|
||||
// TestRun_CheckUnknownContext verifies the typo guard.
|
||||
func TestRun_CheckUnknownContext(t *testing.T) {
|
||||
path := writeSharedPortConfig(t)
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-z"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), `unknown context "team-z"`)
|
||||
// The message should point at what does exist.
|
||||
assert.Contains(t, stderr.String(), "team-a")
|
||||
assert.Contains(t, stderr.String(), "team-b")
|
||||
}
|
||||
|
||||
// TestRun_CheckContextWithNewlyCreatedConfig makes sure -context does not turn
|
||||
// the create-on-missing flow into an "unknown context" dead end.
|
||||
func TestRun_CheckContextWithNewlyCreatedConfig(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader("y\n"), &stdout, &stderr)
|
||||
assert.Equal(t, 0, code, "stderr: %s", stderr.String())
|
||||
assert.Contains(t, stdout.String(), "Configuration is valid")
|
||||
}
|
||||
|
||||
// TestRun_CheckValidatesNonSelectedContexts proves -check did not get weaker:
|
||||
// a broken forward outside the selection is still reported.
|
||||
func TestRun_CheckValidatesNonSelectedContexts(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), ".kportal.yaml")
|
||||
body := `contexts:
|
||||
- name: team-a
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/ok
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 8080
|
||||
- name: team-b
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/bad
|
||||
protocol: tcp
|
||||
port: 80
|
||||
localPort: 999999
|
||||
`
|
||||
require.NoError(t, os.WriteFile(path, []byte(body), 0600))
|
||||
|
||||
var stdout, stderr bytes.Buffer
|
||||
code := run(context.Background(), []string{"-c", path, "-check", "-context", "team-a"}, strings.NewReader(""), &stdout, &stderr)
|
||||
assert.Equal(t, 1, code)
|
||||
assert.Contains(t, stderr.String(), "localPort")
|
||||
}
|
||||
|
||||
// ---- reloadFromSignal ----
|
||||
|
||||
func TestReloadFromSignal_OnAppliedRunsAfterSuccessfulReload(t *testing.T) {
|
||||
mgr, err := forward.NewManager(false)
|
||||
require.NoError(t, err)
|
||||
t.Cleanup(mgr.Stop)
|
||||
cfgPath := writeYAML(t, "r.yaml", `tui:
|
||||
columns:
|
||||
- name: alias
|
||||
contexts:
|
||||
- name: dev
|
||||
namespaces:
|
||||
- name: default
|
||||
forwards:
|
||||
- resource: pod/app
|
||||
port: 8080
|
||||
localPort: 38080
|
||||
`)
|
||||
|
||||
var got *config.Config
|
||||
reloadFromSignal(runOptions{configFile: cfgPath}, mgr, false, func(c *config.Config) { got = c })
|
||||
|
||||
require.NotNil(t, got)
|
||||
require.Len(t, got.GetTableColumns(), 1)
|
||||
assert.Equal(t, "alias", got.GetTableColumns()[0].Name)
|
||||
}
|
||||
|
||||
func TestReloadFromSignal_OnAppliedSkippedWhenConfigInvalid(t *testing.T) {
|
||||
mgr, err := forward.NewManager(false)
|
||||
require.NoError(t, err)
|
||||
cfgPath := writeYAML(t, "bad.yaml", "tui:\n columns:\n - name: bogus\ncontexts: []\n")
|
||||
|
||||
called := false
|
||||
reloadFromSignal(runOptions{configFile: cfgPath}, mgr, false, func(*config.Config) { called = true })
|
||||
|
||||
assert.False(t, called)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user