feat(lifecycle/cli): run native commands with selected modules #16

Merged
end merged 4 commits from feat/lifecycle-cli into main 2026-10-28 14:46:11 -07:00
7 changed files with 837 additions and 0 deletions

No files matched your search

+21
View File
@@ -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.
@@ -0,0 +1,104 @@
---
title: "Run native urfave/cli commands inside selected lifecycle modules"
date: 2026-10-28
status: accepted
implementation: implemented
summary: Use native urfave/cli commands and flags with thin lifecycle action adapters and an explicit exit boundary.
tags:
- lifecycle
- cli
supersedes: []
superseded_by: []
related:
- /docs/adr/20261025T214616275Z-version-light-core-packages-in-the-root-module-and-nest-heavy-dependency-modules.md
- /docs/adr/20261027T184038082Z-supervise-module-lifecycle-and-managed-workers-within-one-shutdown-budget.md
tracking:
- https://gitea.auvem.com/corestack/go/issues/8
implemented_by:
- https://gitea.auvem.com/corestack/go/pulls/16
---
# 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 implementation uses no go-toolkit/appcli source.
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 is published in [PR 16](https://gitea.auvem.com/corestack/go/pulls/16) after independent standard and fresh high implementation reviews with no outstanding findings.
## Considered Options
* 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/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
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, 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) urfavecli.ActionFunc
// Action returns a one-off action using exactly these modules, without services.
func Action(config lifecycle.Config, modules []lifecycle.Module,
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.
`Serve` constructs a fresh lifecycle App, registers selected modules and calls its Run. `Action` copies selected modules with Run cleared, then registers an internal final module with no-op Setup/Teardown and the command function as Run. Choose its nonempty name by checking all selected names and varying a private suffix until unused; no public module name is reserved. All selected setups precede work; returning from the one-off leaves no selected services to keep the App alive. Lifecycle closes admission, cancels the callback context and performs reverse cleanup. No success sentinel, cancellation-error filtering, parent-context cancellation or second shutdown timer is added. Action failures are lifecycle failures and preserve their chains. Core setup failure/partial cleanup, parent cancellation, second signal, abandonment and joined-error behavior remain authoritative. Selected module services cannot run during one-offs. The invocation App is private: modules or callbacks that need its App.Go cannot obtain it through these adapters and are incompatible with this narrow API. Capturing another App does not confer this invocation's supervision. Supported modules manage resources and optional services without that access; raw goroutines remain caller-owned and outside managed-worker completion. Exposing worker access would require a separate owner decision.
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/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 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/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 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 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.
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/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}
cliRoot := &urfavecli.Command{Name: "app", Commands: []*urfavecli.Command{
{Name: "serve", Action: cli.Serve(cfg, []lifecycle.Module{database, server})},
{Name: "inspect", Flags: []urfavecli.Flag{&urfavecli.BoolFlag{Name: "verbose"}},
Action: cli.Action(cfg, []lifecycle.Module{database},
func(ctx context.Context, cmd *urfavecli.Command) error {
return inspect(ctx, db, cmd.Bool("verbose"))
})},
}}
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/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 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. The three adapters, focused tests, executable serve/inspect example and public documentation are implemented in [PR 16](https://gitea.auvem.com/corestack/go/pulls/16). Independent standard and fresh high implementation reviews of `e0673630d5876ece6f2419b4b63ce9f643d5a082` found no Blocking, Major or Minor issues; required local build, vet, race, lint, docs, TODO and formatting checks passed on the reviewed code. Local PostgreSQL integrations skipped without DSNs; final-head CI, PostgreSQL integration evidence and merge readiness are tracked in the PR. Publication is complete, and this record was reconciled against latest `origin/main` at `f0439000cbed0ffdccad0521748bcf1738af4a0c` plus the PR; this records delivery evidence, not CI success, merge or release.
+1
View File
@@ -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
+2
View File
@@ -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=
+230
View File
@@ -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}
}
+428
View File
@@ -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)
}
})
}
}
+51
View File
@@ -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>
}