diff --git a/README.md b/README.md index e547bdf..791a347 100644 --- a/README.md +++ b/README.md @@ -63,6 +63,27 @@ for at least two signals, and subscription cleanup. Nil signals disable receptio There are no signal subscriptions, process exits, external dependencies or CLI integration in this package. +## lifecycle/cli + +`go.auvem.com/corestack/lifecycle/cli` adapts native `urfave/cli/v3` commands. +Bind `Serve(config, modules)` to run selected services, or `Action(config, modules, +fn)` for a one-off with selected setup/cleanup and module services suppressed. +See the [executable serve/inspect example](lifecycle/cli/example_test.go). + +`Run(ctx, cliRoot, args)` accepts argv including the program name and returns the +exit code and inspectable error: success/help/graceful first signal is 0, usage is +2, and runtime/lifecycle failure is 1. Runtime failures dominate mixed errors. +Run prints diagnostics without exiting; the caller owns process exit. Nil signals +subscribe to SIGINT/SIGTERM during adapted actions; injected channels stay caller +owned. The private lifecycle App does not expose managed-worker admission. + +Use fresh command trees and leave `ExitErrHandler`, `OnUsageError` and +`CommandNotFound` unset; Run owns those hooks. Other native hooks run outside the +adapted lifecycle action. Native environment/custom validation and generated-help +parse failures can return 1 with native diagnostics; custom pre-lifecycle +validators may return `urfavecli.Exit(err, 2)` for usage. Help and flags use native +urfave/cli declarations, without a configuration or environment facade. + ## Status No support is guaranteed. Breaking changes, including to the format rules, may happen before 1.0 and are listed in the release notes. diff --git a/docs/adr/20261028T210148136Z-run-native-urfave-commands-inside-selected-lifecycle-modules.md b/docs/adr/20261028T210148136Z-run-native-urfave-commands-inside-selected-lifecycle-modules.md index 68a9381..71631a5 100644 --- a/docs/adr/20261028T210148136Z-run-native-urfave-commands-inside-selected-lifecycle-modules.md +++ b/docs/adr/20261028T210148136Z-run-native-urfave-commands-inside-selected-lifecycle-modules.md @@ -1,9 +1,9 @@ --- -title: "Run native urfave commands inside selected lifecycle modules" +title: "Run native urfave/cli commands inside selected lifecycle modules" date: 2026-10-28 -status: proposed -implementation: not-started -summary: Use native urfave commands and flags with thin lifecycle action adapters and an explicit exit boundary. +status: accepted +implementation: in-progress +summary: Use native urfave/cli commands and flags with thin lifecycle action adapters and an explicit exit boundary. tags: - lifecycle - cli @@ -17,36 +17,36 @@ tracking: implemented_by: [] --- -# Run native urfave commands inside selected lifecycle modules +# Run native urfave/cli commands inside selected lifecycle modules ## Context and Problem Statement -[Issue 8](https://gitea.auvem.com/corestack/go/issues/8) needs command-specific resource setup, signal-aware work, cleanup on completion, automatic help and distinct usage failures. The owner selected `github.com/urfave/cli/v3` v3.14.0 for every command, subcommand, flag and help surface; callers declare native flags. One-offs skip module services; serve runs them. The root-module boundary and lifecycle ordering, cancellation and shared shutdown budget already stand. This clean-room proposal uses no go-toolkit/appcli source. +[Issue 8](https://gitea.auvem.com/corestack/go/issues/8) needs command-specific resource setup, signal-aware work, cleanup on completion, automatic help and distinct usage failures. The owner selected `github.com/urfave/cli/v3` v3.14.0 for every command, subcommand, flag and help surface; callers declare native flags. One-offs skip module services; serve runs them. The root-module boundary and lifecycle ordering, cancellation and shared shutdown budget already stand. This clean-room implementation uses no go-toolkit/appcli source. -All new decisions below remain proposed pending owner acceptance: the three-function API, adapter-owned signal subscription, compatibility limits and exit/diagnostic exceptions. The approved native dependency and one-off service suppression remain settled; this proposal authorizes no implementation. +The owner accepted the three-function API, adapter-owned signal subscription, private invocation App boundary, native validation limitations and generated-help diagnostic/exit exceptions after independent review. The corrected decision was verified at [commit 1b756511](https://gitea.auvem.com/corestack/go/commit/1b7565113c20757c8a049fe44e9a035cf72fd4b7), with R1–R3 addressed and no blocking or major findings; the implementation dispatch explicitly records owner acceptance. Implementation remains in progress pending independent implementation reviews and publication. ## Considered Options -* Native urfave command tree with action adapters: shares native flags/help and existing lifecycle coordination; adds only selection, signal ownership and exit classification. -* A new command/flag schema wrapping urfave: could centralize declarations, but duplicates its API and increases conversion and maintenance obligations without a requested capability. +* Native urfave/cli command tree with action adapters: shares native flags/help and existing lifecycle coordination; adds only selection, signal ownership and exit classification. +* A new command/flag schema wrapping urfave/cli: could centralize declarations, but duplicates its API and increases conversion and maintenance obligations without a requested capability. * Standard-library flag parsing: smaller dependency graph, but would require our own subcommands/help and contradict the owner's dependency choice. -* Per-binary urfave/lifecycle wiring: no library API to maintain, but repeats signal cleanup, one-off service suppression and exit policy in each consumer. +* Per-binary urfave/cli and lifecycle wiring: no library API to maintain, but repeats signal cleanup, one-off service suppression and exit policy in each consumer. ## Decision Outcome -Propose the first option in root package `go.auvem.com/corestack/lifecycle/cli`, with no lifecycle core change. Import urfave as `urfave` to distinguish its native types from this package. The entire new public surface is: +Choose the first option in root package `go.auvem.com/corestack/lifecycle/cli`, with no lifecycle core change. Import urfave/cli as `urfavecli` to distinguish its native types from this package. The entire new public surface is: ```go // Run executes a caller-owned native tree and returns its process code and error. // It prints diagnostics but never exits the process; args includes argv[0]. -func Run(ctx context.Context, root *urfave.Command, args []string) (int, error) +func Run(ctx context.Context, cliRoot *urfavecli.Command, args []string) (int, error) // Serve returns an action running exactly these modules, including their services. -func Serve(config lifecycle.Config, modules []lifecycle.Module) urfave.ActionFunc +func Serve(config lifecycle.Config, modules []lifecycle.Module) urfavecli.ActionFunc // Action returns a one-off action using exactly these modules, without services. func Action(config lifecycle.Config, modules []lifecycle.Module, - fn urfave.ActionFunc) urfave.ActionFunc + fn urfavecli.ActionFunc) urfavecli.ActionFunc ``` The functions capture module declarations in their supplied order; callers list every needed module explicitly. There is no registry, name lookup, implicit dependency expansion or DAG. Callbacks close over caller-owned resource handles populated by Setup; the one-off receives the selected native command for parsed flag access. There is no public App, worker method, configuration framework or flag/environment facade. Empty module lists are valid. Nil one-off functions, nil Run context/root, invalid lifecycle config and invalid selected modules are runtime errors before resource acquisition; validate every selected declaration before starting setup. Bindings do not start work until invoked. A command tree and its mutable resources have one execution owner; construct fresh trees for independent runs and do not mutate captured declarations concurrently. @@ -55,49 +55,49 @@ The functions capture module declarations in their supplied order; callers list Each invoked adapter subscribes to SIGINT/SIGTERM using a private channel buffered for two signals when Config.Signals is nil, and defers signal.Stop. With a supplied channel, subscription, filtering and cleanup remain caller-owned as in lifecycle; a closed supplied channel disables signals. The adapter never closes either channel. No subscription or relay goroutine is needed during parsing/help. Lifecycle alone consumes/counts signals and cancels command work; do not also use signal.NotifyContext, which would turn graceful Ctrl-C into parent cancellation. After adapter return, subscription cleanup restores the prior process signal behavior; the caller owns process exit and abandoned resources. -Run owns the tree's ExitErrHandler, OnUsageError and CommandNotFound hooks, rejecting conflicting caller hooks as runtime configuration errors rather than silently overriding them. It installs a non-exiting root handler and usage/not-found handling on the caller-declared tree before urfave runs; no urfave package globals are changed. CommandNotFound returns void, so its callback records a local usage cause for this invocation; Run combines that cause with the native result even when urfave returns nil, retaining any simultaneous runtime error. Native actions, Before/After, ArgValidator, flags, arguments and help templates remain caller declarations. Hooks outside the lifecycle action are outside its signal/shutdown/resource guarantee: use them for prompt validation, and put resource work inside selected modules or the adapted action. Leave group Actions unset for native group help/unknown-command handling; positional arguments on actionable leaves follow native urfave declarations and validators. +Run owns the tree's ExitErrHandler, OnUsageError and CommandNotFound hooks, rejecting conflicting caller hooks as runtime configuration errors rather than silently overriding them. It installs a non-exiting root handler and usage/not-found handling on the caller-declared tree before urfave/cli runs; no urfave/cli package globals are changed. CommandNotFound returns void, so its callback records a local usage cause for this invocation; Run combines that cause with the native result even when urfave/cli returns nil, retaining any simultaneous runtime error. Native actions, Before/After, ArgValidator, flags, arguments and help templates remain caller declarations. Hooks outside the lifecycle action are outside its signal/shutdown/resource guarantee: use them for prompt validation, and put resource work inside selected modules or the adapted action. Leave group Actions unset for native group help/unknown-command handling; positional arguments on actionable leaves follow native urfave/cli declarations and validators. -The exact proposed mapping is: +The exit mapping is: | Result | Code | | --- | --- | | No returned or locally recorded error, including valid help, empty group help, normal completion or graceful first signal with successful cleanup | 0 | | Lifecycle setup/action/service/teardown error, parent cancellation, budget/second-signal abandonment, or other unclassified native error | 1 | | Native OnUsageError path on caller-declared commands (invalid/missing flag value, required flag/argument, mutually exclusive flags, native argument parsing), or locally recorded unknown command/help topic | 2 | -| Parse failure in a urfave-generated help command, including its aliases | 1 | -| Explicit `urfave.Exit(err, 2)` from pre-lifecycle caller validation | 2 | +| Parse failure in a urfave/cli-generated help command, including its aliases | 1 | +| Explicit `urfavecli.Exit(err, 2)` from pre-lifecycle caller validation | 2 | -A lifecycle failure always remains code 1, including action-returned ExitCoder values and joins containing cleanup failures; do not infer success by matching context.Canceled. Non-2 ExitCoder values outside lifecycle normalize to 1; an error never becomes success through ExitCode 0. Use private provenance wrappers for usage and lifecycle failures. Native After hooks may aggregate failures in urfave.MultiError, whose Errors() children are invisible to standard traversal. A small private bridge traverses both standard Unwrap forms and native MultiError.Errors(), recursively exposing children through Unwrap() []error while also retaining each original error/wrapper as a traversal child. It delegates Error() to the original rather than duplicating its text. Do not flatten or replace native wrappers: errors.Is must still match their identity and errors.As must still find their types, alongside nested causes and private markers. +A lifecycle failure always remains code 1, including action-returned ExitCoder values and joins containing cleanup failures; do not infer success by matching context.Canceled. Non-2 ExitCoder values outside lifecycle normalize to 1; an error never becomes success through ExitCode 0. Use private provenance wrappers for usage and lifecycle failures. Native After hooks may aggregate failures in urfavecli.MultiError, whose Errors() children are invisible to standard traversal. A small private bridge traverses both standard Unwrap forms and native MultiError.Errors(), recursively exposing children through Unwrap() []error while also retaining each original error/wrapper as a traversal child. It delegates Error() to the original rather than duplicating its text. Do not flatten or replace native wrappers: errors.Is must still match their identity and errors.As must still find their types, alongside nested causes and private markers. -Classify every semantic branch, carrying its usage/lifecycle provenance through wrappers and aggregates; aggregate containers are not extra unclassified failures. Any lifecycle branch or unclassified runtime branch dominates usage branches, including an Exit(err, 2) action plus failing After, usage plus runtime After, and nested parent/leaf After failures. Looking up only the first ExitCoder or marker is insufficient. Run returns the traversable error after cleanup and emits at most one adapter-owned final diagnostic to root.ErrWriter. This is not a single total diagnostic guarantee: native help parse handling and caller hooks may also print. Usage hooks request relevant native help through urfave's Show*Help functions. Native help goes to root.Writer and diagnostics to root.ErrWriter, defaulting to stdout/stderr; callers may supply writers there. No logger/writer registry or logging policy is added. Rendering retains urfave's own writer-error behavior, without promising stronger delivery guarantees. +Classify every semantic branch, carrying its usage/lifecycle provenance through wrappers and aggregates; aggregate containers are not extra unclassified failures. Any lifecycle branch or unclassified runtime branch dominates usage branches, including an Exit(err, 2) action plus failing After, usage plus runtime After, and nested parent/leaf After failures. Looking up only the first ExitCoder or marker is insufficient. Run returns the traversable error after cleanup and emits at most one adapter-owned final diagnostic to cliRoot.ErrWriter. This is not a single total diagnostic guarantee: native help parse handling and caller hooks may also print. Usage hooks request relevant native help through urfave/cli's Show*Help functions. Native help goes to cliRoot.Writer and diagnostics to cliRoot.ErrWriter, defaulting to stdout/stderr; callers may supply writers there. No logger/writer registry or logging policy is added. Rendering retains urfave/cli's own writer-error behavior, without promising stronger delivery guarantees. -Proposed owner exception: urfave creates generated help nodes lazily after the pre-run hook walk; their parse failures bypass OnUsageError. Accept code 1 with native diagnostics for malformed generated help invocations (root, nested and aliases); Run's final diagnostic may repeat the native error. Valid help remains code 0, and unknown help topics remain code 2 through the recorded CommandNotFound cause. Uniform code 2 and one total diagnostic would require separate feasibility evidence and owner approval. Do not initialize by reparsing, copy native parser/help code, or detect diagnostics through error strings. +Accepted owner exception: urfave/cli creates generated help nodes lazily after the pre-run hook walk; their parse failures bypass OnUsageError. Accept code 1 with native diagnostics for malformed generated help invocations (root, nested and aliases); Run's final diagnostic may repeat the native error. Valid help remains code 0, and unknown help topics remain code 2 through the recorded CommandNotFound cause. Uniform code 2 and one total diagnostic would require separate feasibility evidence and owner approval. Do not initialize by reparsing, copy native parser/help code, or detect diagnostics through error strings. -Urfave v3.14.0 does not expose all validation errors through OnUsageError: invalid environment values in Flag.PostParse bypass it, as do ArgValidator and flag-action errors. Unclassified errors from those paths are code 1. Callers needing code 2 from custom validators return `urfave.Exit(err, 2)` before lifecycle entry; native environment-source failures remain code 1 unless the caller validates explicitly. Do not inspect error strings, reparse arguments, wrap every flag type or promise universal parse/runtime discrimination. +urfave/cli v3.14.0 does not expose all validation errors through OnUsageError: invalid environment values in Flag.PostParse bypass it, as do ArgValidator and flag-action errors. Unclassified errors from those paths are code 1. Callers needing code 2 from custom validators return `urfavecli.Exit(err, 2)` before lifecycle entry; native environment-source failures remain code 1 unless the caller validates explicitly. Do not inspect error strings, reparse arguments, wrap every flag type or promise universal parse/runtime discrimination. A schematic consumer uses ordinary Go wiring; the implementation Example must be executable and show both modes: ```go cfg := lifecycle.Config{Logger: logger} -root := &urfave.Command{Name: "app", Commands: []*urfave.Command{ +cliRoot := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{ {Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{database, server})}, - {Name: "inspect", Flags: []urfave.Flag{&urfave.BoolFlag{Name: "verbose"}}, + {Name: "inspect", Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}}, Action: cli.Action(cfg, []lifecycle.Module{database}, - func(ctx context.Context, cmd *urfave.Command) error { + func(ctx context.Context, cmd *urfavecli.Command) error { return inspect(ctx, db, cmd.Bool("verbose")) })}, }} -code, err := cli.Run(context.Background(), root, os.Args) +code, err := cli.Run(context.Background(), cliRoot, os.Args) _ = err // already diagnosed; available for inspection by an embedding caller os.Exit(code) // caller-owned process boundary, after all adapter cleanup ``` -The dependency belongs in root go.mod/go.sum under the accepted boundary ADR. At v3.14.0 `go list -deps` reports no nonstandard production package except urfave itself; its [module requirements](https://github.com/urfave/cli/blob/v3.14.0/go.mod) are for tests, with YAML already present here. This is a small CLI dependency, not a reason to introduce a nested module. The native exported types deliberately couple consumers to urfave v3; we own integration tests when upgrading it. +The dependency belongs in root go.mod/go.sum under the accepted boundary ADR. At v3.14.0 `go list -deps` reports no nonstandard production package except urfave/cli itself; its [module requirements](https://github.com/urfave/cli/blob/v3.14.0/go.mod) are for tests, with YAML already present here. This is a small CLI dependency, not a reason to introduce a nested module. The native exported types deliberately couple consumers to urfave/cli v3; we own integration tests when upgrading it. ### Consequences * Scope is one PR, approximately 200–350 production lines plus focused tests and docs: new lifecycle/cli/cli.go (package/public comments and adapters), cli_test.go, example_test.go, root go.mod/go.sum and this ADR. No existing callers, fixtures, lifecycle core, migration package, release/config framework or GUI changes are anticipated. The reusable migration command belongs exclusively to issue 9. * There are no persistence writers, migrations, remote services, new auth boundaries or persistent workers. Active work consists of lifecycle-owned callbacks, its existing shutdown deadline, and a temporary OS signal subscription only while an adapted action executes. Callers own command output, cooperative callbacks, partial setup cleanup and eventual process exit; importing the package starts nothing. * Tests must prove selected-only setup and registration/reverse order, no one-off services, teardown on success/failure, serve services, fixed code mapping with preserved typed causes and mixed cleanup errors, native flags/subcommands/help without module setup, invalid environment classification, injected first/second signals, parent cancellation and the shared deadline. Test with injected channels and explicitly release blocked goroutines; do not send real signals. Exercise native callbacks returning ExitCoder without process exit. Add focused action/lifecycle-plus-After, usage-plus-runtime-After and nested After tests asserting code 1, original wrapper identity/types and all child causes. Cover root/nested generated help parse errors and aliases (code 1/native diagnostics), valid help (code 0), unknown commands/help topics returning native nil (code 2), and synthetic-name collisions without reserving names. -* Native [aggregation and parse handling](https://github.com/urfave/cli/blob/v3.14.0/command_run.go), [MultiError API](https://github.com/urfave/cli/blob/v3.14.0/errors.go) and [lazy help setup](https://github.com/urfave/cli/blob/v3.14.0/command_setup.go) establish integration obligations; they do not verify the unimplemented adapters. Retain these cases in upgrade tests. The simpler alternative is narrower hook support; retaining native hooks with a small traversal bridge preserves the requested cause guarantees. Accepting the generated-help exception avoids a second parser/help subsystem. -* Review and owner acceptance precede implementation. Later standard and fresh high independent implementation reviews, repository checks and reconciliation against the latest target branch precede publication; this draft records no acceptance, production delivery, PR or CI success. +* Native [aggregation and parse handling](https://github.com/urfave/cli/blob/v3.14.0/command_run.go), [MultiError API](https://github.com/urfave/cli/blob/v3.14.0/errors.go) and [lazy help setup](https://github.com/urfave/cli/blob/v3.14.0/command_setup.go) establish integration obligations; they do not alone verify adapter behavior. Retain these cases in upgrade tests. The simpler alternative is narrower hook support; retaining native hooks with a small traversal bridge preserves the requested cause guarantees. Accepting the generated-help exception avoids a second parser/help subsystem. +* Independent ADR review and owner acceptance are complete. Implementation is in progress: the three adapters, focused tests and executable serve/inspect example are present. Standard and fresh high independent implementation reviews, repository checks and reconciliation against the latest target branch precede publication; records were reconciled against latest `origin/main` at `f0439000cbed0ffdccad0521748bcf1738af4a0c` plus this branch, with no PR, CI success, merge or release recorded yet. diff --git a/go.mod b/go.mod index 554f50d..7ee8271 100644 --- a/go.mod +++ b/go.mod @@ -9,6 +9,7 @@ require ( github.com/pressly/goose/v3 v3.29.0 github.com/stephenafamo/bob v0.50.0 github.com/stephenafamo/scan v0.9.0 + github.com/urfave/cli/v3 v3.14.0 github.com/yuin/goldmark v1.8.6 go.yaml.in/yaml/v3 v3.0.5 modernc.org/sqlite v1.60.1 diff --git a/go.sum b/go.sum index 04d1ff7..a812509 100644 --- a/go.sum +++ b/go.sum @@ -141,6 +141,8 @@ github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFA github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI= github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk= github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY= +github.com/urfave/cli/v3 v3.14.0 h1:a8414NQlHJs0c/iBsulKLzlES0n/lEAskbL2LKpU4/s= +github.com/urfave/cli/v3 v3.14.0/go.mod h1:vXn6HxPNccJSzQr2QvwVncOKrgYGIHU0HY5h8B2nQj4= github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07 h1:mJdDDPblDfPe7z7go8Dvv1AJQDI3eQ/5xith3q2mFlo= github.com/wasilibs/go-pgquery v0.0.0-20250409022910-10ac41983c07/go.mod h1:Ak17IJ037caFp4jpCw/iQQ7/W74Sqpb1YuKJU6HTKfM= github.com/wasilibs/wazero-helpers v0.0.0-20240620070341-3dff1577cd52 h1:OvLBa8SqJnZ6P+mjlzc2K7PM22rRUPE1x32G9DTPrC4= diff --git a/lifecycle/cli/cli.go b/lifecycle/cli/cli.go new file mode 100644 index 0000000..1c287b3 --- /dev/null +++ b/lifecycle/cli/cli.go @@ -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} +} diff --git a/lifecycle/cli/cli_test.go b/lifecycle/cli/cli_test.go new file mode 100644 index 0000000..0592444 --- /dev/null +++ b/lifecycle/cli/cli_test.go @@ -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) + } + }) + } +} diff --git a/lifecycle/cli/example_test.go b/lifecycle/cli/example_test.go new file mode 100644 index 0000000..9b4e6b6 --- /dev/null +++ b/lifecycle/cli/example_test.go @@ -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 + // open database + // inspect: connected verbose: true + // close database + // exit: 0 +}