feat(diary): boot --help invocations with the console at warn (quick-261028-rqc)
- Options.Args holds the command-line arguments; FromGlobals fills it from os.Args[1:], so the generated main needs no change - Bootstrap and Setup install the setup with Quiet when Args only ask for help (-h, --help) and no -v is given; files keep their levels - TestBootQuietSmoke runs the generated main's sequence with a plugin that logs from Register and Boot
This commit is contained in:
@@ -10,7 +10,7 @@ Application logging modelled on Laravel's logging config: named channels (consol
|
||||
|
||||
`diary` owns the process logger of a SummerCMS application. `config/log.yaml` names a default channel, a global level and a map of channels, the way Laravel's `config/logging.php` does: a console channel that prints styled lines on a terminal, daily files with retention, and a stack that fans a record out to several channels. Code logs through named component loggers (`diary.For`), so every record carries the plugin or module it came from. Every channel is wrapped by `sunscreen.Wrap`, so credentials are redacted whichever channel a record reaches.
|
||||
|
||||
The application `main` that `summer build` generates reads the console global options first (`bonfire.PeekGlobals`), calls `diary.Bootstrap` with them, which installs a console channel before configuration is read, and `diary.Setup` right after `compass` loads `config/`. Once the command line is parsed, `diary.ApplyGlobals` re-applies `-v`, `-q` and `--ansi`/`--no-ansi`.
|
||||
The application `main` that `summer build` generates reads the console global options first (`bonfire.PeekGlobals`), calls `diary.Bootstrap` with them, which installs a console channel before configuration is read, and `diary.Setup` right after `compass` loads `config/`. `diary.FromGlobals` also records the process arguments, and `diary.Bootstrap` and `diary.Setup` start the console at warnings for an invocation that only asks for help (`serve --help`), unless `-v` is given, so the info lines plugins log while they register and boot do not print above the help. Once the command line is parsed, `diary.ApplyGlobals` re-applies `-v`, `-q` and `--ansi`/`--no-ansi`.
|
||||
|
||||
## Role in the framework
|
||||
|
||||
@@ -34,6 +34,7 @@ Use cases:
|
||||
- Redaction: every channel leaf is wrapped by `sunscreen.Wrap`.
|
||||
- Runtime verbosity: `diary.SetVerbosity` lets info (1) or debug (2) through for every component and adds source locations on styled lines (3); quiet raises only the console channels to warn.
|
||||
- Console global options: `diary.FromGlobals` turns `-v`/`-vv`/`-vvv`, `-q` and `--ansi`/`--no-ansi` into `diary.Options`, and `diary.ApplyGlobals` applies them to the running setup (the generated main registers it with `bonfire.OnGlobals`).
|
||||
- Quiet boot for help: `diary.FromGlobals` also records the process arguments (`os.Args[1:]`) in `diary.Options`, and `diary.Bootstrap` and `diary.Setup` start the console channels at warnings, as `-q` would, when those arguments only ask for help (`-h` or `--help`) and no `-v` is given. Daily files and stderr channels keep their levels, warnings and errors still print, and an existing generated main gets it from a framework upgrade alone. A `diary.Options` literal without arguments does no check.
|
||||
- The process default logger (`slog.Default`) and the standard `log` package log through the configured channels without a component.
|
||||
|
||||
## Usage
|
||||
@@ -86,13 +87,13 @@ Tests that set diary up restore the previous process logger with `t.Cleanup(diar
|
||||
| `diary.Bootstrap` | Installs a console channel on a writer before configuration is read and makes diary the process default logger. |
|
||||
| `diary.Setup` | Parses `log.*` from a `compass` configuration, builds the channels and installs them; returns configuration errors. |
|
||||
| `diary.SetVerbosity` | Changes verbosity and quiet at runtime. |
|
||||
| `diary.FromGlobals` | Returns the `diary.Options` (verbosity, quiet, color mode) of the console global options in a `bonfire.Globals`. |
|
||||
| `diary.FromGlobals` | Returns the `diary.Options` (verbosity, quiet, color mode) of the console global options in a `bonfire.Globals`, with the process arguments (`os.Args[1:]`) in `Args`. |
|
||||
| `diary.ApplyGlobals` | Applies parsed `bonfire.Globals` to the running setup: verbosity and quiet, and the console color mode when it changed. |
|
||||
| `diary.Verbosity` | Returns the current verbosity. |
|
||||
| `diary.HideOnTTY` | Marks a context so a console channel writing to a terminal skips the records logged with it; files, stderr channels and a console that is not a terminal (also one with forced color, `--ansi` or `FORCE_COLOR`) still write them. Used for facts a command has just printed as a block, such as `serve`'s startup summary. |
|
||||
| `diary.Reset` | Drops the setup, closes its files and restores the process default logger captured at the first setup. |
|
||||
| `diary.Close` | Closes the open daily files; later records open them again. |
|
||||
| `diary.Options` | Setup options: `Verbosity`, `Quiet`, `Color`, `Root`, `Stderr`, `Environ` and `Now`. |
|
||||
| `diary.Options` | Setup options: `Verbosity`, `Quiet`, `Color`, `Root`, `Stderr`, `Environ`, `Now` and `Args` (the command-line arguments; when set, an invocation that only asks for help boots with the console at warnings). |
|
||||
| `diary.ParseConfig` | Reads `log.*` over `diary.DefaultConfig`. |
|
||||
| `diary.DefaultConfig` | The framework defaults. |
|
||||
| `diary.Config` | Parsed configuration: `Default`, `Level`, `Channels` and `Components`. |
|
||||
@@ -157,4 +158,4 @@ Environment variables use the `SUMMER_LOG__` prefix with a double underscore bet
|
||||
go test ./modules/diary/...
|
||||
```
|
||||
|
||||
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. `TestConfigEnvSmoke` parses `SUMMER_LOG__` overrides given as strings and checks that every invalid value fails with an error naming its key. `TestDailyDegradesSmoke` points the daily channel at a read-only directory and checks that it warns once and the console keeps logging. The tests write to temporary directories and need no external services.
|
||||
`TestDiaryTracerSmoke` loads a `config/log.yaml` through `compass`, logs one record from a component logger and checks that it reaches the console and the daily file redacted. `TestConfigEnvSmoke` parses `SUMMER_LOG__` overrides given as strings and checks that every invalid value fails with an error naming its key. `TestDailyDegradesSmoke` points the daily channel at a read-only directory and checks that it warns once and the console keeps logging. `TestBootQuietSmoke` runs the generated main's sequence with a plugin that logs while it registers and boots, and checks that `serve --help` keeps its info lines off the console (but not its warning, nor the daily file) while `serve` prints them. The tests write to temporary directories and need no external services.
|
||||
|
||||
191
modules/diary/bootquiet_smoke_test.go
Normal file
191
modules/diary/bootquiet_smoke_test.go
Normal file
@@ -0,0 +1,191 @@
|
||||
package diary_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/backpack"
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/compass"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"git.golem15.com/golem15/summercms/modules/party"
|
||||
)
|
||||
|
||||
// The records the boot-logging test plugin writes.
|
||||
const (
|
||||
bootRegisterInfo = "operators registered"
|
||||
bootBootInfo = "registries collected"
|
||||
bootBootWarn = "registry without operators"
|
||||
)
|
||||
|
||||
// bootLogPlugin logs from Register through slog.Default, the way a plugin
|
||||
// written before diary does, and from Boot through a component logger.
|
||||
type bootLogPlugin struct{}
|
||||
|
||||
func (bootLogPlugin) ID() string { return "acme.bootlog" }
|
||||
func (bootLogPlugin) Requires() []string { return nil }
|
||||
|
||||
func (bootLogPlugin) Register(*backpack.App) error {
|
||||
slog.Default().Info(bootRegisterInfo, "count", 2)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (bootLogPlugin) Boot(*backpack.App) error {
|
||||
log := diary.For("bootlog")
|
||||
log.Info(bootBootInfo, "count", 3)
|
||||
log.Warn(bootBootWarn, "registry", "refunds")
|
||||
return nil
|
||||
}
|
||||
|
||||
func init() {
|
||||
party.Register(bootLogPlugin{})
|
||||
}
|
||||
|
||||
// bootLogYAML is a console plus a daily file at info, as in the tracer test.
|
||||
const bootLogYAML = `default: stack
|
||||
level: info
|
||||
channels:
|
||||
stack:
|
||||
driver: stack
|
||||
channels: [console, daily]
|
||||
console:
|
||||
driver: console
|
||||
format: auto
|
||||
daily:
|
||||
driver: daily
|
||||
path: storage/logs/summer.log
|
||||
days: 14
|
||||
format: json
|
||||
level: info
|
||||
`
|
||||
|
||||
// TestBootQuietSmoke runs the generated main's sequence (PeekGlobals,
|
||||
// Bootstrap and Setup with FromGlobals, party.Activate, a root with
|
||||
// OnGlobals(diary.ApplyGlobals), Execute) with a plugin that logs while it
|
||||
// registers and boots: an invocation that only asks for help keeps the boot
|
||||
// Info records off the console, every other one prints them, and the daily
|
||||
// file and the console's warnings are never affected.
|
||||
func TestBootQuietSmoke(t *testing.T) {
|
||||
rows := []struct {
|
||||
name string
|
||||
args []string
|
||||
literal bool // Options as a struct literal, without Args
|
||||
wantInfo bool // the boot Info records reach the console
|
||||
}{
|
||||
{name: "serve --help", args: []string{"serve", "--help"}},
|
||||
{name: "serve", args: []string{"serve"}, wantInfo: true},
|
||||
{name: "literal options", args: []string{"serve", "--help"}, literal: true, wantInfo: true},
|
||||
}
|
||||
for _, row := range rows {
|
||||
t.Run(row.name, func(t *testing.T) {
|
||||
t.Cleanup(diary.Reset)
|
||||
root := t.TempDir()
|
||||
configDir := filepath.Join(root, "config")
|
||||
if err := os.MkdirAll(configDir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(configDir, "log.yaml"), []byte(bootLogYAML), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var console, stdout, stderr bytes.Buffer
|
||||
now := func() time.Time { return time.Date(2026, 10, 28, 12, 0, 0, 0, time.Local) }
|
||||
|
||||
g := bonfire.PeekGlobals(row.args)
|
||||
var opts diary.Options
|
||||
if row.literal {
|
||||
opts = diary.Options{Verbosity: g.Verbosity, Quiet: g.Quiet, Color: g.Color}
|
||||
} else {
|
||||
opts = diary.FromGlobals(g)
|
||||
opts.Args = slices.Clone(row.args)
|
||||
if opts.Args == nil {
|
||||
opts.Args = []string{}
|
||||
}
|
||||
}
|
||||
opts.Root, opts.Stderr, opts.Environ, opts.Now = root, &console, []string{}, now
|
||||
|
||||
diary.Bootstrap(&console, opts)
|
||||
cfg, err := compass.Open(compass.Options{Dir: configDir, Env: g.Env, Environ: []string{}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := diary.Setup(cfg, opts); err != nil {
|
||||
t.Fatalf("Setup: %v", err)
|
||||
}
|
||||
app := backpack.New(cfg)
|
||||
if _, err := party.Activate(app, []string{"acme.bootlog"}); err != nil {
|
||||
t.Fatalf("Activate: %v", err)
|
||||
}
|
||||
commands := []bonfire.Command{
|
||||
{Name: "serve", Description: "Start the HTTP server", Run: func(context.Context, bonfire.Input, bonfire.Output) error { return nil }},
|
||||
{Name: "queue:work", Description: "Run the job workers", Run: func(context.Context, bonfire.Input, bonfire.Output) error { return nil }},
|
||||
}
|
||||
rootCmd, err := bonfire.NewRootIO("acme", commands, strings.NewReader(""), &stdout, &stderr,
|
||||
bonfire.WithVersion(bonfire.Version{App: "v1.0.0", Framework: "v1.0.0"}), bonfire.OnGlobals(diary.ApplyGlobals))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := bonfire.Execute(context.Background(), rootCmd, row.args); code != bonfire.ExitOK {
|
||||
t.Fatalf("Execute(%q) = %d, want 0; stderr:\n%s", row.args, code, stderr.String())
|
||||
}
|
||||
if slices.Contains(row.args, "--help") && !strings.Contains(stdout.String(), "serve") {
|
||||
t.Errorf("Execute(%q) printed no help:\n%s", row.args, stdout.String())
|
||||
}
|
||||
|
||||
out := console.String()
|
||||
for _, msg := range []string{bootRegisterInfo, bootBootInfo} {
|
||||
if got := strings.Contains(out, msg); got != row.wantInfo {
|
||||
t.Errorf("console holds %q = %v, want %v:\n%s", msg, got, row.wantInfo, out)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(out, bootBootWarn) {
|
||||
t.Errorf("console lacks the boot warning %q:\n%s", bootBootWarn, out)
|
||||
}
|
||||
|
||||
if err := diary.Close(); err != nil {
|
||||
t.Fatalf("Close: %v", err)
|
||||
}
|
||||
files, err := filepath.Glob(filepath.Join(root, "storage", "logs", "*.log"))
|
||||
if err != nil || len(files) != 1 {
|
||||
t.Fatalf("daily files = %v (%v), want one", files, err)
|
||||
}
|
||||
raw, err := os.ReadFile(files[0])
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, msg := range []string{bootRegisterInfo, bootBootInfo, bootBootWarn} {
|
||||
if !bytes.Contains(raw, []byte(msg)) {
|
||||
t.Errorf("daily file lacks %q:\n%s", msg, raw)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestFromGlobalsArgs: FromGlobals records a copy of the process arguments
|
||||
// without the program name, never nil.
|
||||
func TestFromGlobalsArgs(t *testing.T) {
|
||||
saved := os.Args
|
||||
t.Cleanup(func() { os.Args = saved })
|
||||
|
||||
os.Args = []string{"acme", "serve", "--help"}
|
||||
opts := diary.FromGlobals(bonfire.Globals{})
|
||||
if want := []string{"serve", "--help"}; !slices.Equal(opts.Args, want) {
|
||||
t.Fatalf("Args = %q, want %q", opts.Args, want)
|
||||
}
|
||||
opts.Args[0] = "migrate"
|
||||
if os.Args[1] != "serve" {
|
||||
t.Errorf("changing Args changed os.Args to %q", os.Args)
|
||||
}
|
||||
|
||||
os.Args = []string{"acme"}
|
||||
if opts := diary.FromGlobals(bonfire.Globals{}); opts.Args == nil || len(opts.Args) != 0 {
|
||||
t.Errorf("Args = %#v, want an empty non-nil slice", opts.Args)
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ type Options struct {
|
||||
// to debug; 3 or more also adds source locations on the console.
|
||||
Verbosity int
|
||||
// Quiet raises the console channels to warn; files keep their levels.
|
||||
// Bootstrap and Setup also set it for arguments that only ask for help
|
||||
// (see Args).
|
||||
Quiet bool
|
||||
// Color decides on styled console output (bonfire.ColorAuto by default).
|
||||
Color consolekit.ColorMode
|
||||
@@ -43,6 +45,14 @@ type Options struct {
|
||||
Environ []string
|
||||
// Now is the clock of the daily writers; nil means time.Now.
|
||||
Now func() time.Time
|
||||
// Args are the command-line arguments without the program name. When
|
||||
// non-nil, Bootstrap and Setup check whether they only ask for help
|
||||
// and, at verbosity 0, install the setup with Quiet, so the records
|
||||
// plugins log while they register and boot stay off the console.
|
||||
// FromGlobals sets them to a copy of os.Args[1:]; nil (an Options
|
||||
// literal) means no check. A caller that dispatches on other arguments
|
||||
// sets Args itself.
|
||||
Args []string
|
||||
}
|
||||
|
||||
func (o Options) normalize() Options {
|
||||
@@ -116,13 +126,15 @@ func For(component string) *slog.Logger {
|
||||
|
||||
// Bootstrap installs a console channel on w, before configuration is read,
|
||||
// and makes diary the process default logger. The generated main calls it
|
||||
// first; Setup replaces it once config/log.yaml is loaded.
|
||||
// first; Setup replaces it once config/log.yaml is loaded. Like Setup, it
|
||||
// starts the console at warn when opts.Args only ask for help and no -v is
|
||||
// given.
|
||||
func Bootstrap(w io.Writer, opts Options) {
|
||||
if w == nil {
|
||||
w = os.Stderr
|
||||
}
|
||||
opts.Stderr = w
|
||||
opts = opts.normalize()
|
||||
opts = bootQuiet(opts.normalize())
|
||||
cfg := DefaultConfig()
|
||||
cfg.Default = "console"
|
||||
cfg.Channels = map[string]ChannelConfig{"console": {Driver: DriverConsole, Format: FormatAuto}}
|
||||
@@ -138,13 +150,14 @@ func Bootstrap(w io.Writer, opts Options) {
|
||||
// Setup parses log.* from cfg (ParseConfig), builds the channels and makes
|
||||
// them the target of every diary logger and of the process default logger.
|
||||
// A configuration error is returned and leaves the previous setup in place;
|
||||
// a daily file that cannot be opened never fails Setup.
|
||||
// a daily file that cannot be opened never fails Setup. When opts.Args only
|
||||
// ask for help and no -v is given, the console channels start at warn.
|
||||
func Setup(cfg *compass.Config, opts Options) error {
|
||||
conf, err := ParseConfig(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
opts = opts.normalize()
|
||||
opts = bootQuiet(opts.normalize())
|
||||
ch, err := buildChannels(conf, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -215,9 +228,15 @@ func setVerbosityLocked(v int, quiet bool) {
|
||||
// FromGlobals returns the Options for the console global options: their
|
||||
// verbosity, quiet switch and color mode. The generated main passes it to
|
||||
// Bootstrap and Setup. g is a bonfire.Globals: bonfire and diary share the
|
||||
// type through internal/consolekit, so diary does not link bonfire.
|
||||
// type through internal/consolekit, so diary does not link bonfire. It also
|
||||
// sets Args to a copy of os.Args[1:] (empty, not nil, without arguments), so
|
||||
// an invocation that only asks for help boots with the console at warn.
|
||||
func FromGlobals(g consolekit.Globals) Options {
|
||||
return Options{Verbosity: g.Verbosity, Quiet: g.Quiet, Color: g.Color}
|
||||
args := []string{}
|
||||
if len(os.Args) > 1 {
|
||||
args = append(args, os.Args[1:]...)
|
||||
}
|
||||
return Options{Verbosity: g.Verbosity, Quiet: g.Quiet, Color: g.Color, Args: args}
|
||||
}
|
||||
|
||||
// ApplyGlobals applies the parsed global options to the installed setup:
|
||||
|
||||
55
modules/diary/helponly.go
Normal file
55
modules/diary/helponly.go
Normal file
@@ -0,0 +1,55 @@
|
||||
package diary
|
||||
|
||||
// helpOnly reports whether args, a binary's command line without the
|
||||
// program name, only ask for help: before "--" they hold --help or a
|
||||
// single-dash cluster that reaches h. It is a tolerant pre-scan of the raw
|
||||
// arguments, made before plugins boot, and reads them the way
|
||||
// bonfire.PeekGlobals does: it stops at "--", skips the value of --env and
|
||||
// reads a single-dash cluster letter by letter, stopping at the first
|
||||
// letter that is not a global shorthand (v, q, n, h). It never fails and
|
||||
// cannot tell a passthrough command (whose --help reaches its Run) from
|
||||
// another, so "<passthrough> --help" counts; the root's OnGlobals hook
|
||||
// (ApplyGlobals) restores the console levels before that command runs.
|
||||
func helpOnly(args []string) bool {
|
||||
for i := 0; i < len(args); i++ {
|
||||
a := args[i]
|
||||
switch {
|
||||
case a == "--":
|
||||
return false
|
||||
case a == "--env":
|
||||
i++
|
||||
case a == "--help":
|
||||
return true
|
||||
case len(a) >= 2 && a[0] == '-' && a[1] != '-':
|
||||
if clusterHasHelp(a[1:]) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// clusterHasHelp reports whether a shorthand cluster (the letters after the
|
||||
// dash) reaches h before a letter that is not a global shorthand.
|
||||
func clusterHasHelp(cluster string) bool {
|
||||
for i := 0; i < len(cluster); i++ {
|
||||
switch cluster[i] {
|
||||
case 'h':
|
||||
return true
|
||||
case 'v', 'q', 'n':
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// bootQuiet applies the help-only rule of Bootstrap and Setup: with Args
|
||||
// set, no -v and arguments that only ask for help, the setup is installed
|
||||
// with Quiet, which raises only the console channels to warn.
|
||||
func bootQuiet(opts Options) Options {
|
||||
if opts.Args != nil && opts.Verbosity == 0 && helpOnly(opts.Args) {
|
||||
opts.Quiet = true
|
||||
}
|
||||
return opts
|
||||
}
|
||||
Reference in New Issue
Block a user