feat(lifecycle/cli): run native commands with selected modules (#16)
Add native urfave/cli command adapters with explicit module selection, signal-aware execution, reverse cleanup and documented exit-code mapping. Include focused tests, an executable example and the accepted CLI ADR. Closes #8
This commit was merged in pull request #16.
This commit is contained in:
1 parent
f0439000cb
commit
fe44cac3a2
7 files changed
+837
No files matched your search
@@ -0,0 +1,230 @@
|
||||
// Package cli runs caller-owned urfave/cli trees inside selected lifecycle modules.
|
||||
// It owns temporary signal subscriptions and exit classification, not process exit,
|
||||
// configuration, resource handles or unsupervised goroutines.
|
||||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/signal"
|
||||
"slices"
|
||||
"syscall"
|
||||
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
)
|
||||
|
||||
// Run executes cliRoot with args including argv[0], returning 0 for success/help,
|
||||
// 2 for usage, and 1 for runtime failures. Lifecycle failures always take priority.
|
||||
// It prints a final diagnostic to cliRoot.ErrWriter and never exits the process.
|
||||
// Run reserves ExitErrHandler, OnUsageError and CommandNotFound throughout the
|
||||
// declared tree. Construct fresh trees for independent runs; do not mutate them
|
||||
// concurrently. Native generated-help parse and environment validation failures
|
||||
// may return 1 and print native diagnostics in addition to the final diagnostic.
|
||||
func Run(ctx context.Context, cliRoot *urfavecli.Command, args []string) (int, error) {
|
||||
var err error
|
||||
if ctx == nil || cliRoot == nil {
|
||||
err = errors.New("cli: context and root command are required")
|
||||
} else {
|
||||
err = validateTree(cliRoot)
|
||||
}
|
||||
if err == nil {
|
||||
var missing error
|
||||
installHooks(cliRoot, &missing)
|
||||
err = cliRoot.Run(ctx, args)
|
||||
if missing != nil {
|
||||
if err == nil {
|
||||
err = missing
|
||||
} else {
|
||||
err = errors.Join(err, missing)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
return 0, nil
|
||||
}
|
||||
code := classify(err, 0)
|
||||
err = expose(err)
|
||||
if cliRoot != nil && cliRoot.ErrWriter != nil {
|
||||
fmt.Fprintln(cliRoot.ErrWriter, err)
|
||||
} else {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
}
|
||||
return code, err
|
||||
}
|
||||
|
||||
func validateTree(cmd *urfavecli.Command) error {
|
||||
if cmd == nil {
|
||||
return errors.New("cli: nil command")
|
||||
}
|
||||
if cmd.ExitErrHandler != nil || cmd.OnUsageError != nil || cmd.CommandNotFound != nil {
|
||||
return fmt.Errorf("cli: command %q sets a reserved error hook", cmd.Name)
|
||||
}
|
||||
for _, child := range cmd.Commands {
|
||||
if err := validateTree(child); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func installHooks(cmd *urfavecli.Command, missing *error) {
|
||||
cmd.ExitErrHandler = func(context.Context, *urfavecli.Command, error) {}
|
||||
cmd.OnUsageError = func(_ context.Context, selected *urfavecli.Command, err error, _ bool) error {
|
||||
_ = urfavecli.ShowSubcommandHelp(selected)
|
||||
return &failure{err, 2}
|
||||
}
|
||||
cmd.CommandNotFound = func(_ context.Context, selected *urfavecli.Command, name string) {
|
||||
*missing = errors.Join(*missing, &failure{fmt.Errorf("unknown command or help topic %q", name), 2})
|
||||
_ = urfavecli.ShowSubcommandHelp(selected)
|
||||
}
|
||||
for _, child := range cmd.Commands {
|
||||
installHooks(child, missing)
|
||||
}
|
||||
}
|
||||
|
||||
// Serve returns a native action running exactly modules, including their services,
|
||||
// in registration order with reverse cleanup. Bindings capture declarations without
|
||||
// starting work; resources and declarations must not be mutated concurrently.
|
||||
// Nil config.Signals subscribes to SIGINT/SIGTERM for the invocation, buffered for
|
||||
// two signals; an injected channel and its subscription remain caller-owned.
|
||||
// The private invocation App is unavailable for managed-worker admission.
|
||||
func Serve(config lifecycle.Config, modules []lifecycle.Module) urfavecli.ActionFunc {
|
||||
return bind(config, modules, nil, false)
|
||||
}
|
||||
|
||||
// Action returns a native one-off action using exactly modules with their Run
|
||||
// callbacks suppressed. All setups precede fn; completion triggers reverse cleanup.
|
||||
// fn receives lifecycle's signal-aware context and the selected native command.
|
||||
// Nil fn is a runtime configuration error. Signal and ownership rules match Serve;
|
||||
// hooks outside this action are outside the lifecycle shutdown guarantee.
|
||||
func Action(config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc) urfavecli.ActionFunc {
|
||||
return bind(config, modules, fn, true)
|
||||
}
|
||||
|
||||
func bind(config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc, oneOff bool) urfavecli.ActionFunc {
|
||||
modules = slices.Clone(modules)
|
||||
return func(ctx context.Context, cmd *urfavecli.Command) error {
|
||||
err := execute(ctx, cmd, config, modules, fn, oneOff)
|
||||
if err != nil {
|
||||
return &failure{err, 1}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func execute(ctx context.Context, cmd *urfavecli.Command, config lifecycle.Config, modules []lifecycle.Module, fn urfavecli.ActionFunc, oneOff bool) error {
|
||||
if oneOff && fn == nil {
|
||||
return errors.New("cli: one-off function is required")
|
||||
}
|
||||
var signals chan os.Signal
|
||||
if config.Signals == nil {
|
||||
signals = make(chan os.Signal, 2)
|
||||
config.Signals = signals
|
||||
}
|
||||
app, err := lifecycle.New(config)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
names := make(map[string]bool, len(modules))
|
||||
for _, module := range modules {
|
||||
names[module.Name] = true
|
||||
if oneOff {
|
||||
module.Run = nil
|
||||
}
|
||||
if err := app.Register(module); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if oneOff {
|
||||
name := "cli-action"
|
||||
for names[name] {
|
||||
name += "-"
|
||||
}
|
||||
noop := func(context.Context) error { return nil }
|
||||
if err := app.Register(lifecycle.Module{Name: name, Setup: noop, Teardown: noop,
|
||||
Run: func(ctx context.Context) error { return fn(ctx, cmd) }}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if signals != nil {
|
||||
signal.Notify(signals, syscall.SIGINT, syscall.SIGTERM)
|
||||
defer signal.Stop(signals)
|
||||
}
|
||||
return app.Run(ctx)
|
||||
}
|
||||
|
||||
type failure struct {
|
||||
error
|
||||
code int
|
||||
}
|
||||
|
||||
func (e *failure) Unwrap() error { return e.error }
|
||||
|
||||
func children(err error) []error {
|
||||
switch e := err.(type) {
|
||||
case interface{ Unwrap() []error }:
|
||||
return e.Unwrap()
|
||||
case interface{ Unwrap() error }:
|
||||
if child := e.Unwrap(); child != nil {
|
||||
return []error{child}
|
||||
}
|
||||
case urfavecli.MultiError:
|
||||
return e.Errors()
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// invariant: runtime branches dominate usage; aggregate containers add no failure.
|
||||
func classify(err error, inherited int) int {
|
||||
if e, ok := err.(*failure); ok {
|
||||
if e.code == 1 {
|
||||
return 1
|
||||
}
|
||||
inherited = e.code
|
||||
} else if e, ok := err.(urfavecli.ExitCoder); ok && inherited == 0 {
|
||||
inherited = 1
|
||||
if e.ExitCode() == 2 {
|
||||
inherited = 2
|
||||
}
|
||||
}
|
||||
code := 0
|
||||
for _, child := range children(err) {
|
||||
if child == nil {
|
||||
continue
|
||||
}
|
||||
next := classify(child, inherited)
|
||||
if next == 1 {
|
||||
return 1
|
||||
}
|
||||
code = next
|
||||
}
|
||||
if code != 0 {
|
||||
return code
|
||||
}
|
||||
if inherited != 0 {
|
||||
return inherited
|
||||
}
|
||||
return 1
|
||||
}
|
||||
|
||||
// exposed retains native identity and formatting while making opaque MultiError
|
||||
// descendants reachable through standard errors.Is and errors.As traversal.
|
||||
type exposed struct {
|
||||
error
|
||||
causes []error
|
||||
}
|
||||
|
||||
func (e *exposed) Unwrap() []error { return e.causes }
|
||||
|
||||
func expose(err error) error {
|
||||
causes := []error{err}
|
||||
for _, child := range children(err) {
|
||||
if child != nil {
|
||||
causes = append(causes, expose(child))
|
||||
}
|
||||
}
|
||||
return &exposed{err, causes}
|
||||
}
|
||||
@@ -0,0 +1,428 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"reflect"
|
||||
"strings"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
)
|
||||
|
||||
func config() lifecycle.Config {
|
||||
return lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil)), Signals: make(chan os.Signal, 2), ShutdownTimeout: time.Second}
|
||||
}
|
||||
|
||||
func command(action urfavecli.ActionFunc) *urfavecli.Command {
|
||||
return &urfavecli.Command{Name: "app", Writer: io.Discard, ErrWriter: io.Discard, Commands: []*urfavecli.Command{{Name: "inspect", Action: action, Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}}}}}
|
||||
}
|
||||
|
||||
func TestNativeLifecycleFlow(t *testing.T) {
|
||||
for _, fail := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(fail), func(t *testing.T) {
|
||||
var order []string
|
||||
boom := &os.PathError{Op: "inspect", Path: "db", Err: io.ErrUnexpectedEOF}
|
||||
var selected []lifecycle.Module
|
||||
for _, name := range []string{"cli-action", "cli-action-", "database"} {
|
||||
selected = append(selected, lifecycle.Module{Name: name,
|
||||
Setup: func(context.Context) error { order = append(order, "setup "+name); return nil },
|
||||
Run: func(context.Context) error { t.Error("one-off service started"); return nil },
|
||||
Teardown: func(context.Context) error { order = append(order, "close "+name); return nil }})
|
||||
}
|
||||
cliRoot := command(Action(config(), selected, func(_ context.Context, cmd *urfavecli.Command) error {
|
||||
if !cmd.Bool("verbose") {
|
||||
t.Error("native flag missing")
|
||||
}
|
||||
order = append(order, "inspect")
|
||||
if fail {
|
||||
return boom
|
||||
}
|
||||
return nil
|
||||
}))
|
||||
cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "unused", Action: Serve(config(), []lifecycle.Module{{Name: "unused", Setup: func(context.Context) error { t.Error("unselected setup"); return nil }, Teardown: func(context.Context) error { return nil }}})})
|
||||
code, err := Run(context.Background(), cliRoot, []string{"app", "inspect", "--verbose"})
|
||||
if fail && (code != 1 || !errors.Is(err, boom)) || !fail && (code != 0 || err != nil) {
|
||||
t.Fatalf("Run = %d, %v", code, err)
|
||||
}
|
||||
want := []string{"setup cli-action", "setup cli-action-", "setup database", "inspect", "close database", "close cli-action-", "close cli-action"}
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Fatalf("order %v", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeUsageAndHelp(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
args string
|
||||
code int
|
||||
}{
|
||||
{"", 0}, {"--help", 0}, {"inspect --help", 0}, {"help inspect", 0}, {"group", 0}, {"group --help", 0},
|
||||
{"missing", 2}, {"help missing", 2}, {"group missing", 2}, {"group help missing", 2},
|
||||
{"--bad", 2}, {"inspect --bad", 2}, {"inspect --count", 2}, {"inspect --count nope", 2}, {"inspect", 2},
|
||||
{"help --bad", 1}, {"h --bad", 1}, {"group help --bad", 1}, {"group h --bad", 1},
|
||||
} {
|
||||
t.Run(tc.args, func(t *testing.T) {
|
||||
cliRoot := command(Action(config(), []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup during usage/help"); return nil }, Teardown: func(context.Context) error { return nil }}}, func(context.Context, *urfavecli.Command) error { t.Error("action during usage/help"); return nil }))
|
||||
cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.IntFlag{Name: "count", Required: true})
|
||||
cliRoot.Commands = append(cliRoot.Commands, &urfavecli.Command{Name: "group", Commands: []*urfavecli.Command{{Name: "child"}}})
|
||||
var output, diagnostic bytes.Buffer
|
||||
cliRoot.Writer = &output
|
||||
cliRoot.ErrWriter = &diagnostic
|
||||
code, err := Run(context.Background(), cliRoot, append([]string{"app"}, strings.Fields(tc.args)...))
|
||||
if code != tc.code || (err != nil) != (tc.code != 0) {
|
||||
t.Fatalf("Run=%d,%v; want %d", code, err, tc.code)
|
||||
}
|
||||
if output.Len() == 0 && tc.code != 1 {
|
||||
t.Error("missing native help")
|
||||
}
|
||||
if (diagnostic.Len() > 0) != (tc.code != 0) {
|
||||
t.Fatalf("diagnostic %q", diagnostic.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNativeValidation(t *testing.T) {
|
||||
for _, kind := range []string{"environment", "validator", "validator-exit", "flag-action", "required-argument", "argument-parse", "exclusive"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
cliRoot := command(func(context.Context, *urfavecli.Command) error {
|
||||
t.Error("invalid invocation reached action")
|
||||
return nil
|
||||
})
|
||||
cmd := cliRoot.Commands[0]
|
||||
args := []string{"app", "inspect"}
|
||||
want := 1
|
||||
switch kind {
|
||||
case "environment":
|
||||
t.Setenv("CORESTACK_CLI_COUNT", "oops")
|
||||
cmd.Flags = []urfavecli.Flag{&urfavecli.IntFlag{Name: "count", Sources: urfavecli.EnvVars("CORESTACK_CLI_COUNT")}}
|
||||
case "validator", "validator-exit":
|
||||
cmd.ArgValidator = func(context.Context, *urfavecli.Command) error {
|
||||
if kind == "validator-exit" {
|
||||
return urfavecli.Exit("invalid", 2)
|
||||
}
|
||||
return errors.New("invalid")
|
||||
}
|
||||
if kind == "validator-exit" {
|
||||
want = 2
|
||||
}
|
||||
case "flag-action":
|
||||
cmd.Flags = []urfavecli.Flag{&urfavecli.BoolFlag{Name: "bad", Action: func(context.Context, *urfavecli.Command, bool) error { return errors.New("invalid") }}}
|
||||
args = append(args, "--bad")
|
||||
case "required-argument":
|
||||
cmd.Arguments = []urfavecli.Argument{&urfavecli.StringArg{Name: "target", Required: true}}
|
||||
want = 2
|
||||
case "argument-parse":
|
||||
cmd.Arguments = []urfavecli.Argument{&urfavecli.IntArg{Name: "count"}}
|
||||
args = append(args, "oops")
|
||||
want = 2
|
||||
case "exclusive":
|
||||
cmd.MutuallyExclusiveFlags = []urfavecli.MutuallyExclusiveFlags{{Flags: [][]urfavecli.Flag{{&urfavecli.BoolFlag{Name: "a"}}, {&urfavecli.BoolFlag{Name: "b"}}}}}
|
||||
args = append(args, "--a", "--b")
|
||||
want = 2
|
||||
}
|
||||
code, err := Run(context.Background(), cliRoot, args)
|
||||
if code != want || err == nil {
|
||||
t.Fatalf("Run=%d,%v; want %d", code, err, want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFailureCategoriesAndCauses(t *testing.T) {
|
||||
for _, kind := range []string{"native-exit2", "native-after", "native-exit0", "native-exit9", "action-exit2", "teardown", "setup", "action-after", "usage-after", "nested-after", "unknown-after"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
cause := &os.PathError{Op: "read", Path: "db", Err: io.ErrUnexpectedEOF}
|
||||
after := errors.New("after")
|
||||
parent := errors.New("parent after")
|
||||
original := urfavecli.Exit(cause, 2)
|
||||
cfg := config()
|
||||
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { return nil }}}
|
||||
action := func(context.Context, *urfavecli.Command) error { return original }
|
||||
want := 1
|
||||
args := []string{"app", "inspect"}
|
||||
switch kind {
|
||||
case "native-exit2":
|
||||
want = 2
|
||||
case "native-exit0":
|
||||
original = urfavecli.Exit(cause, 0)
|
||||
case "native-exit9":
|
||||
original = urfavecli.Exit(cause, 9)
|
||||
case "setup":
|
||||
modules[0].Setup = func(context.Context) error { return cause }
|
||||
case "teardown":
|
||||
modules[0].Teardown = func(context.Context) error { return after }
|
||||
}
|
||||
cliRoot := command(action)
|
||||
if !strings.HasPrefix(kind, "native-") {
|
||||
cliRoot.Commands[0].Action = Action(cfg, modules, action)
|
||||
}
|
||||
if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" {
|
||||
cliRoot.Commands[0].After = func(context.Context, *urfavecli.Command) error { return after }
|
||||
}
|
||||
if kind == "nested-after" {
|
||||
cliRoot.After = func(context.Context, *urfavecli.Command) error { return parent }
|
||||
}
|
||||
if kind == "usage-after" {
|
||||
cliRoot.Commands[0].Flags = append(cliRoot.Commands[0].Flags, &urfavecli.StringFlag{Name: "needed", Required: true})
|
||||
}
|
||||
if kind == "unknown-after" {
|
||||
args = []string{"app", "help", "missing"}
|
||||
cliRoot.After = func(context.Context, *urfavecli.Command) error { return after }
|
||||
}
|
||||
code, err := Run(context.Background(), cliRoot, args)
|
||||
if code != want || err == nil {
|
||||
t.Fatalf("Run=%d,%v", code, err)
|
||||
}
|
||||
if kind != "usage-after" && kind != "unknown-after" {
|
||||
var path *os.PathError
|
||||
if !errors.Is(err, cause) || !errors.As(err, &path) || path != cause || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("cause lost: %v", err)
|
||||
}
|
||||
if kind != "setup" && !errors.Is(err, original) {
|
||||
t.Fatalf("original identity lost: %v", err)
|
||||
}
|
||||
}
|
||||
if kind == "teardown" || strings.HasSuffix(kind, "-after") {
|
||||
if !errors.Is(err, after) {
|
||||
t.Fatalf("After/cleanup lost: %v", err)
|
||||
}
|
||||
}
|
||||
if kind == "nested-after" && !errors.Is(err, parent) {
|
||||
t.Fatal("parent cause lost")
|
||||
}
|
||||
if kind == "native-after" || kind == "action-after" || kind == "nested-after" || kind == "usage-after" {
|
||||
var multi urfavecli.MultiError
|
||||
if !errors.As(err, &multi) || !errors.Is(err, multi) {
|
||||
t.Fatalf("native aggregate lost: %v", err)
|
||||
}
|
||||
if err.Error() != multi.Error() {
|
||||
t.Fatalf("duplicated formatting: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationBeforeSetup(t *testing.T) {
|
||||
for _, kind := range []string{"nil-context", "nil-root", "nil-function", "nil-logger", "negative-timeout", "invalid-module", "duplicate-module", "nil-child", "exit-hook", "usage-hook", "not-found-hook"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
cfg := config()
|
||||
noop := func(context.Context) error { return nil }
|
||||
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { t.Error("setup before validation"); return nil }, Teardown: noop}}
|
||||
var ctx context.Context = context.Background()
|
||||
fn := urfavecli.ActionFunc(func(context.Context, *urfavecli.Command) error { t.Error("action before validation"); return nil })
|
||||
switch kind {
|
||||
case "nil-context":
|
||||
ctx = nil
|
||||
case "nil-function":
|
||||
fn = nil
|
||||
case "nil-logger":
|
||||
cfg.Logger = nil
|
||||
case "negative-timeout":
|
||||
cfg.ShutdownTimeout = -1
|
||||
case "invalid-module":
|
||||
modules = append(modules, lifecycle.Module{Name: "invalid"})
|
||||
case "duplicate-module":
|
||||
modules = append(modules, modules[0])
|
||||
}
|
||||
cliRoot := command(Action(cfg, modules, fn))
|
||||
switch kind {
|
||||
case "nil-root":
|
||||
cliRoot = nil
|
||||
case "nil-child":
|
||||
cliRoot.Commands = append(cliRoot.Commands, nil)
|
||||
case "exit-hook":
|
||||
cliRoot.Commands[0].ExitErrHandler = func(context.Context, *urfavecli.Command, error) {}
|
||||
case "usage-hook":
|
||||
cliRoot.Commands[0].OnUsageError = func(context.Context, *urfavecli.Command, error, bool) error { return nil }
|
||||
case "not-found-hook":
|
||||
cliRoot.Commands[0].CommandNotFound = func(context.Context, *urfavecli.Command, string) {}
|
||||
}
|
||||
code, err := Run(ctx, cliRoot, []string{"app", "inspect"})
|
||||
if code != 1 || err == nil {
|
||||
t.Fatalf("Run=%d,%v", code, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
func await[T any](t *testing.T, ch <-chan T) T {
|
||||
t.Helper()
|
||||
select {
|
||||
case value := <-ch:
|
||||
return value
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("callback blocked")
|
||||
var zero T
|
||||
return zero
|
||||
}
|
||||
}
|
||||
|
||||
func TestSignalsAndParent(t *testing.T) {
|
||||
for _, mode := range []string{"first", "second", "parent", "serve"} {
|
||||
t.Run(mode, func(t *testing.T) {
|
||||
signals := make(chan os.Signal, 2)
|
||||
cfg := config()
|
||||
cfg.Signals = signals
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
ready := make(chan struct{})
|
||||
canceled := make(chan struct{})
|
||||
exited := make(chan struct{})
|
||||
cleanup := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
defer func() { close(release); await(t, exited) }()
|
||||
callback := func(ctx context.Context) error {
|
||||
close(ready)
|
||||
defer close(exited)
|
||||
<-ctx.Done()
|
||||
close(canceled)
|
||||
if mode == "second" {
|
||||
<-release
|
||||
}
|
||||
return ctx.Err()
|
||||
}
|
||||
modules := []lifecycle.Module{{Name: "db", Setup: func(context.Context) error { return nil }, Teardown: func(context.Context) error { close(cleanup); return nil }, Run: callback}}
|
||||
action := Action(cfg, modules, func(ctx context.Context, _ *urfavecli.Command) error { return callback(ctx) })
|
||||
if mode == "serve" {
|
||||
action = Serve(cfg, modules)
|
||||
}
|
||||
done := make(chan struct {
|
||||
code int
|
||||
err error
|
||||
}, 1)
|
||||
go func() {
|
||||
code, err := Run(ctx, command(action), []string{"app", "inspect"})
|
||||
done <- struct {
|
||||
code int
|
||||
err error
|
||||
}{code, err}
|
||||
}()
|
||||
await(t, ready)
|
||||
if mode == "parent" {
|
||||
cancel()
|
||||
} else {
|
||||
signals <- syscall.SIGINT
|
||||
}
|
||||
await(t, canceled)
|
||||
await(t, cleanup)
|
||||
if mode == "second" {
|
||||
signals <- syscall.SIGTERM
|
||||
}
|
||||
result := await(t, done)
|
||||
want := 0
|
||||
if mode == "second" || mode == "parent" {
|
||||
want = 1
|
||||
}
|
||||
if result.code != want || (result.err != nil) != (want != 0) {
|
||||
t.Fatalf("Run=%d,%v", result.code, result.err)
|
||||
}
|
||||
if want == 1 && !errors.Is(result.err, context.Canceled) {
|
||||
t.Fatalf("cancellation lost: %v", result.err)
|
||||
}
|
||||
// The adapter must leave the injected channel open and reusable.
|
||||
signals <- syscall.SIGTERM
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSharedShutdownDeadline(t *testing.T) {
|
||||
cfg := config()
|
||||
cfg.ShutdownTimeout = 100 * time.Millisecond
|
||||
ready := make(chan struct{})
|
||||
shutdown := make(chan time.Time, 1)
|
||||
exited := make(chan struct{})
|
||||
release := make(chan struct{})
|
||||
defer func() { close(release); await(t, exited) }()
|
||||
var first time.Time
|
||||
noop := func(context.Context) error { return nil }
|
||||
modules := []lifecycle.Module{
|
||||
{Name: "blocked", Setup: noop, Teardown: func(ctx context.Context) error {
|
||||
defer close(exited)
|
||||
deadline, _ := ctx.Deadline()
|
||||
shutdown <- deadline
|
||||
<-release
|
||||
return nil
|
||||
}},
|
||||
{Name: "first", Setup: noop, Teardown: func(ctx context.Context) error {
|
||||
first, _ = ctx.Deadline()
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
return nil
|
||||
}},
|
||||
}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := Run(context.Background(), command(Action(cfg, modules, func(context.Context, *urfavecli.Command) error { close(ready); return nil })), []string{"app", "inspect"})
|
||||
done <- err
|
||||
}()
|
||||
await(t, ready)
|
||||
deadline := await(t, shutdown)
|
||||
if !first.Equal(deadline) {
|
||||
t.Fatal("shutdown deadline reset between hooks")
|
||||
}
|
||||
if err := await(t, done); !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("deadline lost: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptySelectionsAndOwnedSignals(t *testing.T) {
|
||||
for _, oneOff := range []bool{false, true} {
|
||||
cfg := config()
|
||||
cfg.Signals = nil
|
||||
action := Serve(cfg, nil)
|
||||
if oneOff {
|
||||
action = Action(cfg, nil, func(context.Context, *urfavecli.Command) error { return nil })
|
||||
}
|
||||
if code, err := Run(context.Background(), command(action), []string{"app", "inspect"}); code != 0 || err != nil {
|
||||
t.Fatalf("Run=%d,%v", code, err)
|
||||
}
|
||||
}
|
||||
cfg := config()
|
||||
signals := make(chan os.Signal)
|
||||
close(signals)
|
||||
cfg.Signals = signals
|
||||
if code, err := Run(context.Background(), command(Serve(cfg, nil)), []string{"app", "inspect"}); code != 0 || err != nil {
|
||||
t.Fatalf("closed signals: %d,%v", code, err)
|
||||
}
|
||||
}
|
||||
func TestPartialSetupAndMixedCleanup(t *testing.T) {
|
||||
for _, setupFailure := range []bool{false, true} {
|
||||
t.Run(fmt.Sprint(setupFailure), func(t *testing.T) {
|
||||
initiating := urfavecli.Exit(io.ErrUnexpectedEOF, 2)
|
||||
cleanup := errors.New("cleanup")
|
||||
var order []string
|
||||
modules := []lifecycle.Module{
|
||||
{Name: "first", Setup: func(context.Context) error { order = append(order, "first setup"); return nil }, Teardown: func(context.Context) error { order = append(order, "first cleanup"); return cleanup }},
|
||||
{Name: "second", Setup: func(context.Context) error {
|
||||
order = append(order, "second setup")
|
||||
if setupFailure {
|
||||
return initiating
|
||||
}
|
||||
return nil
|
||||
}, Teardown: func(context.Context) error { order = append(order, "second cleanup"); return nil }},
|
||||
}
|
||||
cliRoot := command(Action(config(), modules, func(context.Context, *urfavecli.Command) error { order = append(order, "action"); return initiating }))
|
||||
code, err := Run(context.Background(), cliRoot, []string{"app", "inspect"})
|
||||
if code != 1 || !errors.Is(err, initiating) || !errors.Is(err, cleanup) || !errors.Is(err, io.ErrUnexpectedEOF) {
|
||||
t.Fatalf("Run=%d,%v", code, err)
|
||||
}
|
||||
want := []string{"first setup", "second setup", "first cleanup"}
|
||||
if !setupFailure {
|
||||
want = []string{"first setup", "second setup", "action", "second cleanup", "first cleanup"}
|
||||
}
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Fatalf("order %v", order)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package cli_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
||||
urfavecli "github.com/urfave/cli/v3"
|
||||
"go.auvem.com/corestack/lifecycle"
|
||||
"go.auvem.com/corestack/lifecycle/cli"
|
||||
)
|
||||
|
||||
func Example() {
|
||||
cfg := lifecycle.Config{Logger: slog.New(slog.NewTextHandler(io.Discard, nil))}
|
||||
for _, args := range [][]string{{"app", "serve"}, {"app", "inspect", "--verbose"}} {
|
||||
var database string
|
||||
db := lifecycle.Module{Name: "database",
|
||||
Setup: func(context.Context) error { database = "connected"; fmt.Println("open database"); return nil },
|
||||
Teardown: func(context.Context) error { fmt.Println("close database"); database = ""; return nil },
|
||||
}
|
||||
server := lifecycle.Module{Name: "server",
|
||||
Setup: func(context.Context) error { fmt.Println("open server"); return nil },
|
||||
// A real server blocks until cancellation; this finite service keeps the example executable.
|
||||
Run: func(context.Context) error { fmt.Println("serve:", database); return nil },
|
||||
Teardown: func(context.Context) error { fmt.Println("close server"); return nil },
|
||||
}
|
||||
cliRoot := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
|
||||
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{db, server})},
|
||||
{Name: "inspect", Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}},
|
||||
Action: cli.Action(cfg, []lifecycle.Module{db}, func(_ context.Context, cmd *urfavecli.Command) error {
|
||||
fmt.Println("inspect:", database, "verbose:", cmd.Bool("verbose"))
|
||||
return nil
|
||||
})},
|
||||
}}
|
||||
code, err := cli.Run(context.Background(), cliRoot, args)
|
||||
fmt.Println("exit:", code, err)
|
||||
// main may call os.Exit(code) here, after adapter cleanup.
|
||||
}
|
||||
// Output:
|
||||
// open database
|
||||
// open server
|
||||
// serve: connected
|
||||
// close server
|
||||
// close database
|
||||
// exit: 0 <nil>
|
||||
// open database
|
||||
// inspect: connected verbose: true
|
||||
// close database
|
||||
// exit: 0 <nil>
|
||||
}
|
||||
Reference in new issue
Block a user