Send diagnostics to stderr and stop dropping log attributes (closes #82)
All checks were successful
check / check (pull_request) Successful in 2m34s

Two defects in internal/log, fixed together because both live in the
handler construction path.

Logger on stdout (#82). Initialize built both handlers over os.Stdout.
Every --json subcommand writes its document to that same stream, and
WARN/ERROR are never suppressed by any flag, so a log record could land
inside a JSON document and break the parse. This was not theoretical: a
config file with group- or world-readable permissions triggers a WARN
during startup, which was enough to make `snapshot list --json | jq`
fail. Diagnostics now go to stderr, and the TTY/JSON format choice
follows stderr's TTY-ness rather than stdout's -- testing the wrong
stream would colorize records on a redirected stderr whenever stdout
happened to be a terminal, and emit JSON to a terminal in the reverse
case.

This is user-visible: --verbose and --debug output moves to stderr as
well, so `vaultik snapshot list -v > out.txt` no longer captures the
diagnostics. README.md documents the split under a new "stdout and
stderr" section. --quiet and --cron semantics are untouched: level
selection is unchanged, and warnings and errors are still emitted in
both modes.

It also retires the local workaround in internal/vaultik/snapshot_list.go.
warnWhileListing had been hand-rolling structured-log formatting to reach
a writer that was not stdout, and the jsonOutput parameter threaded
through the remote-listing helpers existed only to choose between the two
writers; both are gone, and those warnings go through log.Warn in every
mode. The collect-then-emit machinery around listingWarning stays, on
its remaining merit rather than its original one: slog handlers are safe
for concurrent use, but emitting from the manifest-fetch workers would
order warnings by network timing, where collecting and emitting in key
order after group.Wait makes two runs over the same damaged store
produce the same diagnostics in the same order.

TTYHandler dropped attributes (#97). WithAttrs and WithGroup discarded
their arguments and returned the receiver, while their doc comments
claimed the opposite. Because the handler is chosen by TTY-ness, this
failed only on a terminal and worked correctly in CI -- so it broke
exactly when someone was debugging interactively. Both now return a new
handler: the receiver is never written to, since slog permits a handler
to be shared and derived from concurrently, and the derived handler
copies its slices rather than reslicing so two concurrent derivations
cannot overwrite each other's attributes. The mutex became a pointer so
handlers sharing a stream keep sharing one lock. Attributes persist
across every subsequent record, and grouping is implemented as dotted
key prefixes, which is the only honest rendering for a format with
nowhere to nest.

Tests: an attribute attached through the exported log.With reaches
TTYHandler output; attributes persist across records; groups qualify
keys; deriving does not leak between siblings or back to the parent;
sixteen goroutines derive from and write through one handler under
-race; and the TTY and JSON handlers are fed identical derivation chains
and compared attribute set by attribute set, which is the test that
would have caught the original defect and the one that stops the two
paths drifting again. All of these fail against the unfixed handler.

The existing snapshot-list tests that asserted these warnings on an
injected writer now capture the process's real stderr, which is where
they go; the assertions are otherwise unchanged.
This commit is contained in:
2026-08-09 16:25:50 +00:00
parent e3f407b440
commit b7aee81fa5
9 changed files with 841 additions and 162 deletions

View File

@@ -113,11 +113,32 @@ vaultik version
### global flags ### global flags
* `--config <path>`: Path to config file (default: `$VAULTIK_CONFIG`, then platform config dir, then `/etc/vaultik/config.yml`) * `--config <path>`: Path to config file (default: `$VAULTIK_CONFIG`, then platform config dir, then `/etc/vaultik/config.yml`)
* `--verbose`, `-v`: Enable verbose output * `--verbose`, `-v`: Enable verbose output (on stderr — see below)
* `--debug`: Enable debug output * `--debug`: Enable debug output (on stderr — see below)
* `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner) * `--quiet`, `-q`: Suppress non-error output (also suppresses startup banner)
* `--skip-errors`: Continue past per-file errors instead of aborting (applies to `snapshot create` and `restore`) * `--skip-errors`: Continue past per-file errors instead of aborting (applies to `snapshot create` and `restore`)
### stdout and stderr
Log output — everything from `--verbose` and `--debug`, and every
warning and error the logger emits — goes to **stderr**. stdout carries
the output you asked for: tables, and the documents produced by `--json`.
This means `vaultik snapshot list --verbose > out.txt` captures the
listing and leaves the diagnostics on your terminal. To capture both,
redirect stderr as well (`> out.txt 2> log.txt`, or `> out.txt 2>&1` to
interleave them).
The split is what makes `--json` usable from a script. Warnings and
errors are never suppressed — not by `--quiet`, not by `--cron` — so a
logger on stdout would eventually land a log line inside a JSON
document and break the parse. A config file with group- or
world-readable permissions is enough to trigger it.
Format follows the stream: when stderr is a terminal the records are
colorized one-liners, and when it is redirected or piped they are
JSON, one object per line.
### environment variables ### environment variables
* `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`) * `VAULTIK_AGE_SECRET_KEY`: Age private key for decryption (required for `snapshot restore` and `snapshot verify --deep`)
@@ -208,8 +229,9 @@ local index alone, and still exits zero.
(whether the snapshot is in the local index), `remote_key` (the full (whether the snapshot is in the local index), `remote_key` (the full
64-character storage key), and `remote_present` (whether it was seen 64-character storage key), and `remote_present` (whether it was seen
on the destination store, or `null` if the destination could not be on the destination store, or `null` if the destination could not be
listed). The warning about an unlistable destination goes to stderr listed). Warnings about an unlistable destination, unreadable
so stdout stays a single parseable document. manifests, and a truncated listing all go to stderr through the
logger, so stdout stays a single parseable document.
**`snapshot verify`**: Verify snapshot integrity. **`snapshot verify`**: Verify snapshot integrity.
* Default (shallow): checks that all blobs referenced in the manifest exist in storage * Default (shallow): checks that all blobs referenced in the manifest exist in storage
@@ -504,6 +526,10 @@ All user-facing output goes through helpers in `internal/ui` and conforms
to a uniform style. Color is enabled when stdout is a TTY and the to a uniform style. Color is enabled when stdout is a TTY and the
`NO_COLOR` environment variable is unset (https://no-color.org/). `NO_COLOR` environment variable is unset (https://no-color.org/).
`internal/ui` writes to stdout; it is the output the user asked for.
Structured log records are a different thing and go through
`internal/log`, which writes to stderr (see "stdout and stderr" above).
Message classes: Message classes:
| Class | Marker | Alignment | Use for | | Class | Marker | Alignment | Use for |

39
TODO.md
View File

@@ -25,6 +25,45 @@ release" is exactly the contradiction
# Completed Steps # Completed Steps
- 2026-08-09: Moved the logger to stderr and fixed `TTYHandler`'s
discarded attributes
([issue #82](https://git.eeqj.de/sneak/vaultik/issues/82),
[issue #97](https://git.eeqj.de/sneak/vaultik/issues/97)). Two defects
in `internal/log`, fixed together because both live in the handler
construction path. The first: both handlers were built over
`os.Stdout`, and `WARN`/`ERROR` are never suppressed, so a config file
with group- or world-readable permissions was enough to put a log
record inside a `--json` document and break `jq`. Diagnostics now go
to stderr, and the TTY/JSON format choice follows stderr rather than
stdout — testing the wrong stream would colorize records on a
redirected stderr whenever stdout happened to be a terminal. This is
user-visible: `--verbose` and `--debug` output moves to stderr too,
which is documented in `README.md` under "stdout and stderr". It also
let the local workaround in `internal/vaultik/snapshot_list.go` go:
`warnWhileListing` had been hand-rolling structured-log formatting to
reach a non-stdout writer, and the `jsonOutput` parameter threaded
through the remote-listing helpers existed only to choose between the
two writers. The collect-then-emit machinery around `listingWarning`
stays, but on its remaining merit — warnings emitted in key order
after `group.Wait()` are deterministic run to run, where emitting from
the fetch workers would order them by network timing. The second
defect: `TTYHandler.WithAttrs` and `WithGroup` discarded their
arguments and returned the receiver while their doc comments claimed
otherwise, so `log.With` attributes vanished on a terminal and
appeared correctly in CI — failing precisely when someone is debugging
interactively. Both now return a new handler (the receiver is never
written to, since `slog` permits concurrent derivation), attributes
persist across records, and grouping is implemented as dotted key
prefixes, which is the only honest rendering for a format with nowhere
to nest. New tests cover both, including one that feeds the same
derivation chain to the TTY and JSON handlers and compares the
attribute sets, so the two paths cannot drift apart again. Found and
filed while verifying: the startup banner is written to stdout and
`--json` does not suppress it
([issue #106](https://git.eeqj.de/sneak/vaultik/issues/106)), which is
a separate writer on a separate path and the remaining source of
stdout contamination.
- 2026-08-09: Made the tagged-release path actually work on Gitea - 2026-08-09: Made the tagged-release path actually work on Gitea
([issue #65](https://git.eeqj.de/sneak/vaultik/issues/65)). Three ([issue #65](https://git.eeqj.de/sneak/vaultik/issues/65)). Three
independent blockers, one of which was the whole independent blockers, one of which was the whole

View File

@@ -1,5 +1,9 @@
// Package log provides the application-wide structured logger: slog // Package log provides the application-wide structured logger: slog
// with a colorized TTY handler on terminals and JSON output otherwise. // writing to stderr, with a colorized TTY handler when stderr is a
// terminal and JSON output otherwise.
//
// Everything this package emits is a diagnostic, so it all goes to
// stderr. stdout belongs to the output the user asked for.
package log //nolint:revive,nolintlint // stdlib log unused here; see #76 package log //nolint:revive,nolintlint // stdlib log unused here; see #76
import ( import (
@@ -69,13 +73,27 @@ func Initialize(cfg Config) {
Level: level, Level: level,
} }
// Check if stdout is a TTY. // Diagnostics go to stderr, never to stdout. stdout is reserved for
if term.IsTerminal(int(os.Stdout.Fd())) { // the output the user asked for: every --json subcommand writes its
// document there, and WARN/ERROR are never suppressed, so a logger
// on stdout puts log records inside that document and makes it
// unparseable. A config file with group- or world-readable
// permissions is enough to trigger it (see internal/config), so this
// was not a theoretical collision.
//
// The format is chosen by the TTY-ness of the stream the records
// actually land on. AGENTS.md policy 9 says "if stdout is not a
// terminal, emit jsonl"; it says stdout because that is where logs
// used to go, and the property it is really asking for is that
// output nobody is watching be machine-readable. Testing stdout here
// would colorize records on a redirected stderr whenever stdout
// happened to be a terminal, and vice versa.
if term.IsTerminal(int(os.Stderr.Fd())) {
// Use colorized TTY handler // Use colorized TTY handler
logger = slog.New(NewTTYHandler(os.Stdout, opts)) logger = slog.New(NewTTYHandler(os.Stderr, opts))
} else { } else {
// Use JSON format for non-TTY output // Use JSON format for non-TTY output
logger = slog.New(slog.NewJSONHandler(os.Stdout, opts)) logger = slog.New(slog.NewJSONHandler(os.Stderr, opts))
} }
// Set as default logger // Set as default logger

View File

@@ -5,10 +5,20 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"strings"
"sync" "sync"
"time" "time"
) )
// groupSeparator joins an open group path to an attribute key. This
// format has no nesting, so a group becomes a dotted key prefix:
// slog.New(h).WithGroup("db").With("rows", 3) renders "db.rows=3".
const groupSeparator = "."
// bytesAttrKey is the attribute key whose int64 value is rendered as a
// human-readable byte count rather than a bare number.
const bytesAttrKey = "bytes"
// ANSI color codes // ANSI color codes
const ( const (
colorReset = "\033[0m" colorReset = "\033[0m"
@@ -22,10 +32,26 @@ const (
) )
// TTYHandler is a custom slog handler for TTY output with colors. // TTYHandler is a custom slog handler for TTY output with colors.
//
// A handler and the handlers derived from it via WithAttrs/WithGroup
// all write to the same stream, so they share one mutex; that is why mu
// is a pointer. A value mutex would give every derived handler its own
// lock and stop serializing writes to the stream they have in common.
type TTYHandler struct { type TTYHandler struct {
opts slog.HandlerOptions opts slog.HandlerOptions
mu sync.Mutex mu *sync.Mutex
out io.Writer out io.Writer
// attrs are the attributes accumulated through WithAttrs, emitted
// ahead of each record's own attributes. Their keys already carry
// the group path that was open when they were added, so no
// qualification happens at write time.
attrs []slog.Attr
// groups is the group path opened by WithGroup, applied as a key
// prefix to attributes that arrive later — both on a record and
// through a further WithAttrs.
groups []string
} }
// NewTTYHandler creates a new TTY handler with colored output. // NewTTYHandler creates a new TTY handler with colored output.
@@ -37,6 +63,7 @@ func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler {
return &TTYHandler{ return &TTYHandler{
out: out, out: out,
opts: *opts, opts: *opts,
mu: &sync.Mutex{},
} }
} }
@@ -81,8 +108,126 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
levelColor, level, colorReset, levelColor, level, colorReset,
colorBold, r.Message, colorReset) colorBold, r.Message, colorReset)
// Print attributes // Attributes carried by the handler come first, then the record's
// own. Handler attributes were qualified when they were added; the
// record's are qualified now, against whatever group path is open.
for _, a := range h.attrs {
h.writeAttr(a)
}
prefix := strings.Join(h.groups, groupSeparator)
r.Attrs(func(a slog.Attr) bool { r.Attrs(func(a slog.Attr) bool {
for _, flat := range appendAttr(nil, prefix, a) {
h.writeAttr(flat)
}
return true
})
_, _ = fmt.Fprintln(h.out)
return nil
}
// appendAttr flattens a into dst, folding prefix into its key and
// expanding group values into further dotted keys. Following the
// slog.Handler contract: an empty Attr is dropped, a group with no
// attributes is dropped, and a group with an empty key is inlined into
// its parent rather than contributing a level.
func appendAttr(dst []slog.Attr, prefix string, a slog.Attr) []slog.Attr {
a.Value = a.Value.Resolve()
if a.Equal(slog.Attr{}) {
return dst
}
key := a.Key
switch {
case prefix == "":
// key stands alone.
case key == "":
key = prefix
default:
key = prefix + groupSeparator + key
}
if a.Value.Kind() != slog.KindGroup {
return append(dst, slog.Attr{Key: key, Value: a.Value})
}
for _, member := range a.Value.Group() {
dst = appendAttr(dst, key, member)
}
return dst
}
// WithAttrs returns a new handler that emits attrs on every record it
// handles, in addition to whatever the handler already carried. Keys
// are qualified by the group path open at the time of the call, so
// WithGroup("db").WithAttrs(rows=3) later renders "db.rows=3".
//
// The receiver is not modified.
func (h *TTYHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return h
}
prefix := strings.Join(h.groups, groupSeparator)
next := h.clone()
for _, a := range attrs {
next.attrs = appendAttr(next.attrs, prefix, a)
}
return next
}
// WithGroup returns a new handler that qualifies every subsequent
// attribute key with name. This format is a single line with nowhere to
// nest, so grouping is rendered as a dotted key prefix: after
// WithGroup("db"), an attribute "rows" is emitted as "db.rows".
//
// An empty name returns the receiver unchanged, per the slog.Handler
// contract. The receiver is not modified.
func (h *TTYHandler) WithGroup(name string) slog.Handler {
if name == "" {
return h
}
next := h.clone()
next.groups = append(next.groups, name)
return next
}
// clone returns a copy of h that shares its output stream and mutex but
// owns its attribute and group slices.
//
// The slices are copied rather than resliced on purpose. slog permits
// one handler to be derived from concurrently, and two derivations that
// appended into a shared backing array would each overwrite the other's
// attribute — a data race with a silent wrong-output failure mode.
func (h *TTYHandler) clone() *TTYHandler {
next := &TTYHandler{
opts: h.opts,
mu: h.mu,
out: h.out,
attrs: make([]slog.Attr, len(h.attrs), len(h.attrs)+1),
groups: make([]string, len(h.groups), len(h.groups)+1),
}
copy(next.attrs, h.attrs)
copy(next.groups, h.groups)
return next
}
// writeAttr renders one already-flattened, already-qualified attribute
// as " key=value". Callers hold h.mu.
func (h *TTYHandler) writeAttr(a slog.Attr) {
value := a.Value.String() value := a.Value.String()
// Special handling for certain attribute types // Special handling for certain attribute types
switch a.Value.Kind() { switch a.Value.Kind() {
@@ -91,7 +236,7 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
value = formatDuration(d) value = formatDuration(d)
} }
case slog.KindInt64: case slog.KindInt64:
if a.Key == "bytes" { if a.Key == bytesAttrKey {
value = formatBytes(a.Value.Int64()) value = formatBytes(a.Value.Int64())
} }
case slog.KindAny, slog.KindBool, slog.KindFloat64, slog.KindString, case slog.KindAny, slog.KindBool, slog.KindFloat64, slog.KindString,
@@ -104,23 +249,6 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
_, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s", _, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s",
colorCyan, a.Key, colorReset, colorCyan, a.Key, colorReset,
colorBlue, value, colorReset) colorBlue, value, colorReset)
return true
})
_, _ = fmt.Fprintln(h.out)
return nil
}
// WithAttrs returns a new handler with the given attributes.
func (h *TTYHandler) WithAttrs(_ []slog.Attr) slog.Handler {
return h // Simplified for now
}
// WithGroup returns a new handler with the given group name.
func (h *TTYHandler) WithGroup(_ string) slog.Handler {
return h // Simplified for now
} }
// formatDuration formats a duration in a human-readable way // formatDuration formats a duration in a human-readable way

View File

@@ -0,0 +1,377 @@
package log_test
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log/slog"
"math"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"sneak.berlin/go/vaultik/internal/log"
)
// ansiEscape matches the SGR sequences TTYHandler wraps every field in.
// Stripping them is what lets a test compare TTYHandler's rendering with
// slog.JSONHandler's.
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// countKey is an attribute key reused across the comparison cases.
const countKey = "count"
// debugHandlerOptions enables every level, so a test never has to reason
// about the default level while reasoning about attributes.
func debugHandlerOptions() *slog.HandlerOptions {
return &slog.HandlerOptions{Level: slog.LevelDebug}
}
// ttyAttrs renders one record through a TTYHandler and returns its
// attributes as key -> value, with color stripped.
//
// TTYHandler emits " key=value" per attribute after the message, and the
// message itself is the last thing before the first attribute, so
// splitting on spaces and keeping the tokens containing "=" recovers the
// attribute set. Test values below therefore avoid spaces and "=".
func ttyAttrs(t *testing.T, derive func(*slog.Logger) *slog.Logger,
msg string, args ...any,
) map[string]string {
t.Helper()
var buf bytes.Buffer
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
derive(logger).Info(msg, args...)
line := ansiEscape.ReplaceAllString(buf.String(), "")
attrs := make(map[string]string)
for token := range strings.FieldsSeq(line) {
key, value, found := strings.Cut(token, "=")
if !found {
continue
}
attrs[key] = value
}
return attrs
}
// jsonAttrs renders one record through slog.JSONHandler and returns its
// attributes flattened to the same dotted-key form TTYHandler uses, so
// the two are directly comparable. The built-in time/level/msg fields
// are dropped: they are the record, not its attributes.
func jsonAttrs(t *testing.T, derive func(*slog.Logger) *slog.Logger,
msg string, args ...any,
) map[string]string {
t.Helper()
var buf bytes.Buffer
logger := slog.New(slog.NewJSONHandler(&buf, debugHandlerOptions()))
derive(logger).Info(msg, args...)
var decoded map[string]any
require.NoError(t, json.Unmarshal(buf.Bytes(), &decoded))
delete(decoded, slog.TimeKey)
delete(decoded, slog.LevelKey)
delete(decoded, slog.MessageKey)
attrs := make(map[string]string)
flattenJSON(attrs, "", decoded)
return attrs
}
// flattenJSON turns JSONHandler's nested group objects into the dotted
// keys TTYHandler writes.
func flattenJSON(dst map[string]string, prefix string, src map[string]any) {
for key, value := range src {
full := key
if prefix != "" {
full = prefix + "." + key
}
nested, ok := value.(map[string]any)
if ok {
flattenJSON(dst, full, nested)
continue
}
dst[full] = valueString(value)
}
}
// valueString renders a decoded JSON scalar the way slog.Value.String
// renders the corresponding Go value, so the two handlers' outputs can
// be compared as strings. encoding/json decodes every number as
// float64, so an integral one is rendered back as an integer — which is
// what the Go value that produced it was.
func valueString(v any) string {
switch typed := v.(type) {
case string:
return typed
case bool:
return strconv.FormatBool(typed)
case float64:
if typed == math.Trunc(typed) {
return strconv.FormatInt(int64(typed), 10)
}
return strconv.FormatFloat(typed, 'g', -1, 64)
default:
return fmt.Sprint(v)
}
}
// TestTTYHandlerWithAttrsEmitsAttributes is the direct regression test
// for the reported defect: WithAttrs discarded its argument, so an
// attribute attached to a logger never reached the output.
func TestTTYHandlerWithAttrsEmitsAttributes(t *testing.T) {
t.Parallel()
attrs := ttyAttrs(t, func(l *slog.Logger) *slog.Logger {
return l.With("key", "value")
}, "hello")
assert.Equal(t, "value", attrs["key"],
"an attribute attached with With must appear on every record")
}
// TestTTYHandlerWithAttrsPersistsAcrossRecords checks that the
// attributes are retained rather than emitted once. A handler that
// stored them but consumed them would pass the test above.
func TestTTYHandlerWithAttrsPersistsAcrossRecords(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions())).
With("request", "abc123")
logger.Info("first")
logger.Info("second")
plain := ansiEscape.ReplaceAllString(buf.String(), "")
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
require.Len(t, lines, 2)
for _, line := range lines {
assert.Contains(t, line, "request=abc123")
}
}
// TestTTYHandlerWithGroupQualifiesKeys checks that WithGroup does
// something real rather than being discarded. This format has no
// nesting, so grouping shows up as a dotted key prefix.
func TestTTYHandlerWithGroupQualifiesKeys(t *testing.T) {
t.Parallel()
attrs := ttyAttrs(t, func(l *slog.Logger) *slog.Logger {
return l.WithGroup("db").With("rows", 3)
}, "queried", "table", "chunks")
assert.Equal(t, "3", attrs["db.rows"],
"an attribute added under a group must be qualified by it")
assert.Equal(t, "chunks", attrs["db.table"],
"a record attribute must also be qualified by the open group")
assert.NotContains(t, attrs, "rows")
}
// TestTTYHandlerMatchesJSONHandlerAttributes is the drift guard. The
// handler is chosen by TTY-ness, so a difference between these two is
// invisible in whichever environment the developer is not in — which is
// how the original defect survived: attributes vanished on a terminal
// and were correct in CI.
func TestTTYHandlerMatchesJSONHandlerAttributes(t *testing.T) {
t.Parallel()
cases := []struct {
name string
derive func(*slog.Logger) *slog.Logger
args []any
}{
{
name: "record attributes only",
derive: func(l *slog.Logger) *slog.Logger { return l },
args: []any{"path", "/etc/vaultik", countKey, 7},
},
{
name: "handler attributes",
derive: func(l *slog.Logger) *slog.Logger {
return l.With("host", "alpha")
},
args: []any{countKey, 7},
},
{
name: "handler attributes accumulate",
derive: func(l *slog.Logger) *slog.Logger {
return l.With("host", "alpha").With("snapshot", "s1")
},
args: []any{countKey, 7},
},
{
name: "group qualifies later attributes",
derive: func(l *slog.Logger) *slog.Logger {
return l.WithGroup("db").With("rows", 3)
},
args: []any{"table", "chunks"},
},
{
name: "nested groups",
derive: func(l *slog.Logger) *slog.Logger {
return l.WithGroup("outer").WithGroup("inner").
With("leaf", "v")
},
args: []any{"other", "w"},
},
{
name: "attributes before and after a group",
derive: func(l *slog.Logger) *slog.Logger {
return l.With("top", "t").WithGroup("g").With("in", "i")
},
args: []any{"rec", "r"},
},
{
name: "inline group value on the record",
derive: func(l *slog.Logger) *slog.Logger { return l },
args: []any{slog.Group("net",
slog.String("proto", "s3"), slog.Int("retries", 2))},
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
t.Parallel()
tty := ttyAttrs(t, testCase.derive, "message", testCase.args...)
js := jsonAttrs(t, testCase.derive, "message", testCase.args...)
assert.Equal(t, sortedKeys(js), sortedKeys(tty),
"TTY and JSON handlers must emit the same attribute keys")
assert.Equal(t, js, tty,
"TTY and JSON handlers must emit the same attribute values")
})
}
}
// sortedKeys returns m's keys in order, for a stable comparison message.
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}
// TestTTYHandlerWithAttrsDoesNotMutateReceiver checks that deriving does
// not write through to the parent or to a sibling. slog permits a
// handler to be shared, so a WithAttrs that appended into the receiver's
// state would leak attributes between unrelated loggers.
func TestTTYHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
base := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
first := base.With("branch", "one")
second := base.With("branch", "two")
base.Info("base")
first.Info("first")
second.Info("second")
plain := ansiEscape.ReplaceAllString(buf.String(), "")
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
require.Len(t, lines, 3)
assert.NotContains(t, lines[0], "branch=",
"deriving must not add attributes to the handler derived from")
assert.Contains(t, lines[1], "branch=one")
assert.NotContains(t, lines[1], "branch=two")
assert.Contains(t, lines[2], "branch=two")
assert.NotContains(t, lines[2], "branch=one")
}
// TestTTYHandlerConcurrentDerivation exercises the same handler being
// derived from and written through by several goroutines at once, which
// is what slog permits and what a mutating WithAttrs would make a data
// race. Run under -race by script/test.
func TestTTYHandlerConcurrentDerivation(t *testing.T) {
t.Parallel()
const workers = 16
var buf bytes.Buffer
base := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions())).
With("shared", "yes")
var group sync.WaitGroup
group.Add(workers)
for worker := range workers {
go func() {
defer group.Done()
base.With("worker", worker).
WithGroup("g").
With("nested", worker).
Info("concurrent")
}()
}
group.Wait()
plain := ansiEscape.ReplaceAllString(buf.String(), "")
lines := strings.Split(strings.TrimSuffix(plain, "\n"), "\n")
require.Len(t, lines, workers)
for _, line := range lines {
assert.Contains(t, line, "shared=yes")
assert.Contains(t, line, "worker=")
assert.Contains(t, line, "g.nested=")
}
}
// TestTTYHandlerEmptyGroupAndAttrsAreNoOps covers the slog.Handler
// contract corners: WithGroup("") and WithAttrs(nil) change nothing, and
// an empty Attr is dropped rather than rendered as "=".
func TestTTYHandlerEmptyGroupAndAttrsAreNoOps(t *testing.T) {
t.Parallel()
var buf bytes.Buffer
handler := log.NewTTYHandler(&buf, debugHandlerOptions())
assert.Same(t, handler, handler.WithGroup(""),
"an empty group name must not open a group")
assert.Same(t, handler, handler.WithAttrs(nil),
"deriving with no attributes must not allocate a handler")
slog.New(handler).LogAttrs(context.Background(), slog.LevelInfo, "msg",
slog.Attr{}, slog.String("kept", "yes"))
plain := ansiEscape.ReplaceAllString(buf.String(), "")
assert.Contains(t, plain, "kept=yes")
assert.NotContains(t, plain, " =")
}

64
internal/log/with_test.go Normal file
View File

@@ -0,0 +1,64 @@
//nolint:testpackage // needs the package logger; see TestWithAttributesReachTTYOutput
package log //nolint:revive,nolintlint // stdlib log unused here; see #76
import (
"bytes"
"log/slog"
"regexp"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// withTestANSIEscape matches the SGR sequences TTYHandler emits.
var withTestANSIEscape = regexp.MustCompile(`\x1b\[[0-9;]*m`)
// TestWithAttributesReachTTYOutput exercises the exported package-level
// With through a TTYHandler, which is the path the reported defect was
// on: the handler is selected by TTY-ness, so on a terminal With's
// attributes were silently dropped while the same code printed them
// correctly in CI.
//
// This is an in-package test so it can point the package logger at a
// buffer. Building an slog.Logger over a TTYHandler by hand would test
// slog, not this package's With, and there is no injectable sink to
// reach it from outside. The package logger is process-global, so this
// test must not run in parallel.
//
//nolint:paralleltest // replaces the process-global package logger
func TestWithAttributesReachTTYOutput(t *testing.T) {
var buf bytes.Buffer
previous := logger
t.Cleanup(func() { logger = previous })
logger = slog.New(NewTTYHandler(&buf, &slog.HandlerOptions{
Level: slog.LevelDebug,
}))
With("key", "value").Info("hello")
plain := withTestANSIEscape.ReplaceAllString(buf.String(), "")
require.NotEmpty(t, plain)
assert.Contains(t, plain, "hello")
assert.Contains(t, plain, "key=value",
"log.With attributes must reach TTYHandler output")
}
// TestWithoutInitializedLoggerFallsBack pins the documented behavior of
// With before Initialize has run: it hands back the slog default rather
// than a nil logger that would panic at the call site.
//
//nolint:paralleltest // replaces the process-global package logger
func TestWithoutInitializedLoggerFallsBack(t *testing.T) {
previous := logger
t.Cleanup(func() { logger = previous })
logger = nil
assert.NotNil(t, With("key", "value"))
}

View File

@@ -87,7 +87,7 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
snapshots = append(snapshots, info) snapshots = append(snapshots, info)
} }
listing, remoteErr := v.collectRemoteSnapshots(localKeys, jsonOutput) listing, remoteErr := v.collectRemoteSnapshots(localKeys)
if remoteErr != nil { if remoteErr != nil {
v.warnRemoteListingFailed(remoteErr, jsonOutput) v.warnRemoteListingFailed(remoteErr, jsonOutput)
} else { } else {
@@ -131,23 +131,23 @@ func (v *Vaultik) ListSnapshots(jsonOutput bool) error {
// still worth printing, and `snapshot list` exiting non-zero because a // still worth printing, and `snapshot list` exiting non-zero because a
// volume is unmounted would be worse than useless. // volume is unmounted would be worse than useless.
// //
// In --json mode the warning goes to stderr rather than through the // The two output modes report it through different channels. Table mode
// logger or the UI writer, both of which emit on stdout — the JSON // uses the UI writer, whose prose and color match the table it sits
// document has to be the only thing on stdout for `snapshot list --json // under. The UI writer emits on stdout, though, so --json mode uses the
// | jq` to work. The failure is also representable in the document // logger instead: stdout has to hold nothing but the JSON document for
// itself: every row's remote_present is null when the destination could // `snapshot list --json | jq` to work. Both channels are chosen once,
// not be listed. // never both, so the user is not told the same thing twice.
//
// The failure is also representable in the document itself: every row's
// remote_present is null when the destination could not be listed.
func (v *Vaultik) warnRemoteListingFailed(err error, jsonOutput bool) { func (v *Vaultik) warnRemoteListingFailed(err error, jsonOutput bool) {
if jsonOutput { if jsonOutput {
_, _ = fmt.Fprintf(v.Stderr, log.Warn("Could not list backup destination store; "+
"Warning: could not list backup destination store: %v. "+ "showing snapshots from the local index only", "error", err)
"Showing snapshots from the local index only.\n", err)
return return
} }
// Once only: the logger also writes to stdout, so emitting through
// both it and the UI would print the same sentence to the user twice.
v.UI.Warningf("Could not list backup destination store: %v.", err) v.UI.Warningf("Could not list backup destination store: %v.", err)
v.UI.Infof("Showing snapshots from the local index only.") v.UI.Infof("Showing snapshots from the local index only.")
} }
@@ -156,69 +156,29 @@ func (v *Vaultik) warnRemoteListingFailed(err error, jsonOutput bool) {
// is about to read is incomplete: manifests that could not be read, and // is about to read is incomplete: manifests that could not be read, and
// remote-only snapshots dropped by the maxRemoteOnlyRows cap. // remote-only snapshots dropped by the maxRemoteOnlyRows cap.
// //
// Table mode reports both below the table (see reportListDrift). In // Table mode reports both below the table (see reportListDrift) through
// --json mode they cannot go on stdout — the document has to be the // the UI writer, which emits on stdout. In --json mode stdout has to
// only thing there for `snapshot list --json | jq` to work — and the // hold nothing but the document for `snapshot list --json | jq` to
// document's shape is deliberately left alone so existing consumers // work, and the document's shape is deliberately left alone so existing
// keep parsing. So they go to stderr, the same place the // consumers keep parsing — so these go to the logger, which writes to
// unreachable-destination warning already goes. A consumer that must // stderr. A consumer that must react to truncation can treat any output
// react to truncation can treat any output on this stream as "this // on that stream as "this listing is not the whole picture"; silent
// listing is not the whole picture"; silent truncation of a listing // truncation of a listing whose whole purpose is disaster recovery is
// whose whole purpose is disaster recovery is the worse failure. // the worse failure.
func (v *Vaultik) reportJSONListingLimits(listing *remoteSnapshotListing) { func (v *Vaultik) reportJSONListingLimits(listing *remoteSnapshotListing) {
if listing.unreadable > 0 { if listing.unreadable > 0 {
_, _ = fmt.Fprintf(v.Stderr, log.Warn("Some remote snapshot(s) could not be described: "+
"Warning: %d remote snapshot(s) could not be described: "+ "manifest missing or unreadable; they are missing from "+
"manifest missing or unreadable. They are missing from "+ "this listing", "unreadable", listing.unreadable)
"this listing.\n", listing.unreadable)
} }
if listing.omitted > 0 { if listing.omitted > 0 {
_, _ = fmt.Fprintf(v.Stderr, log.Warn("Listing truncated: further remote-only snapshot(s) "+
"Warning: listing truncated: %d further remote-only "+ "not shown", "omitted", listing.omitted,
"snapshot(s) not shown (limit %d per listing).\n", "limit", maxRemoteOnlyRows)
listing.omitted, maxRemoteOnlyRows)
} }
} }
// kvPairSize is the number of variadic arguments that make up one
// structured logging key/value pair.
const kvPairSize = 2
// warnWhileListing reports a per-snapshot problem found while
// describing the destination store, through a writer that is safe for
// the current output mode.
//
// In --json mode it writes to v.Stderr rather than calling log.Warn,
// for the same reason warnRemoteListingFailed does: internal/log builds
// its logger over os.Stdout and defaults to level Warn, so one warning
// there would put a log line on stdout ahead of the JSON document and
// break `snapshot list --json | jq`. A single corrupt manifest is
// precisely the degradation this listing is built to survive, so it
// must not be the thing that corrupts the output.
//
// This is a local workaround. Remove it, and the branch in
// warnRemoteListingFailed, once issue #82 makes the logger's sink
// configurable.
func (v *Vaultik) warnWhileListing(jsonOutput bool, msg string, args ...any) {
if !jsonOutput {
log.Warn(msg, args...)
return
}
var line strings.Builder
_, _ = fmt.Fprintf(&line, "Warning: %s", msg)
for i := 0; i+kvPairSize <= len(args); i += kvPairSize {
pair := args[i : i+kvPairSize]
_, _ = fmt.Fprintf(&line, " %v=%v", pair[0], pair[1])
}
_, _ = fmt.Fprintln(v.Stderr, line.String())
}
// remoteSnapshotListing is the result of one pass over the destination // remoteSnapshotListing is the result of one pass over the destination
// store's metadata/ prefix. // store's metadata/ prefix.
type remoteSnapshotListing struct { type remoteSnapshotListing struct {
@@ -247,11 +207,8 @@ type remoteSnapshotListing struct {
// Manifest reads scale only with the number of snapshots the local // Manifest reads scale only with the number of snapshots the local
// index does not already know about, and are capped at // index does not already know about, and are capped at
// maxRemoteOnlyRows. // maxRemoteOnlyRows.
//
// jsonOutput only selects where per-snapshot warnings are written; see
// warnWhileListing.
func (v *Vaultik) collectRemoteSnapshots( func (v *Vaultik) collectRemoteSnapshots(
localKeys map[string]bool, jsonOutput bool, localKeys map[string]bool,
) (*remoteSnapshotListing, error) { ) (*remoteSnapshotListing, error) {
keys, err := v.listAllRemoteSnapshotKeys() keys, err := v.listAllRemoteSnapshotKeys()
if err != nil { if err != nil {
@@ -282,17 +239,22 @@ func (v *Vaultik) collectRemoteSnapshots(
} }
listing.remoteOnly, listing.unreadable = v.describeRemoteOnlySnapshots( listing.remoteOnly, listing.unreadable = v.describeRemoteOnlySnapshots(
unknown, jsonOutput) unknown)
return listing, nil return listing, nil
} }
// listingWarning is a problem found with one remote snapshot, recorded // listingWarning is a problem found with one remote snapshot, recorded
// rather than emitted on the spot. Manifest reads run concurrently and // rather than emitted on the spot. Manifest reads run concurrently, so
// the writer chosen by warnWhileListing is not guaranteed to be safe // emitting from the worker that found the problem would order the
// for concurrent use, so warnings are held until every read has // warnings by fetch completion — which varies run to run with network
// finished and then emitted in key order from a single goroutine. That // timing and tells the reader nothing. Holding them and emitting in key
// also makes the warning order deterministic run to run. // order from a single goroutine after every read has finished makes two
// runs over the same damaged store produce the same diagnostics in the
// same order.
//
// Concurrency safety is no longer part of the reason: these are emitted
// through log.Warn, and slog handlers are safe for concurrent use.
type listingWarning struct { type listingWarning struct {
msg string msg string
args []any args []any
@@ -306,7 +268,7 @@ type listingWarning struct {
// failing the listing: one bad snapshot directory must not hide every // failing the listing: one bad snapshot directory must not hide every
// other snapshot the user has. // other snapshot the user has.
func (v *Vaultik) describeRemoteOnlySnapshots( func (v *Vaultik) describeRemoteOnlySnapshots(
keys []string, jsonOutput bool, keys []string,
) ([]SnapshotInfo, int) { ) ([]SnapshotInfo, int) {
found := make([]SnapshotInfo, len(keys)) found := make([]SnapshotInfo, len(keys))
ok := make([]bool, len(keys)) ok := make([]bool, len(keys))
@@ -349,7 +311,7 @@ func (v *Vaultik) describeRemoteOnlySnapshots(
for i := range keys { for i := range keys {
if warnings[i] != nil { if warnings[i] != nil {
v.warnWhileListing(jsonOutput, warnings[i].msg, warnings[i].args...) log.Warn(warnings[i].msg, warnings[i].args...)
} }
if !ok[i] { if !ok[i] {

View File

@@ -521,16 +521,16 @@ func TestListSnapshots_JSONMergedView(t *testing.T) {
// TestListSnapshots_JSONUnreachableRemote checks that a failed listing // TestListSnapshots_JSONUnreachableRemote checks that a failed listing
// does not corrupt the JSON document with warning text, and that // does not corrupt the JSON document with warning text, and that
// "unknown" is reported as null rather than as absence. // "unknown" is reported as null rather than as absence.
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONUnreachableRemote(t *testing.T) { func TestListSnapshots_JSONUnreachableRemote(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t) env := newListEnv(t)
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC)) env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
env.store.listErr = errRemoteUnreachable env.store.listErr = errRemoteUnreachable
err := env.v.ListSnapshots(true) stderr := captureProcessStderr(t, func() {
require.NoError(t, err) require.NoError(t, env.v.ListSnapshots(true))
})
// stdout must be nothing but the JSON document, so the warning has // stdout must be nothing but the JSON document, so the warning has
// to go to stderr. // to go to stderr.
@@ -542,9 +542,8 @@ func TestListSnapshots_JSONUnreachableRemote(t *testing.T) {
assert.Nil(t, rows[0].RemotePresent, assert.Nil(t, rows[0].RemotePresent,
"remote state is unknown when the destination cannot be listed") "remote state is unknown when the destination cannot be listed")
assert.Contains(t, env.stderr.String(), assert.Contains(t, stderr, "Could not list backup destination store")
"could not list backup destination store") assert.Contains(t, stderr, "permission denied")
assert.Contains(t, env.stderr.String(), "permission denied")
} }
// useNonUTCLocalZone points time.Local at a fixed non-UTC zone for the // useNonUTCLocalZone points time.Local at a fixed non-UTC zone for the
@@ -635,10 +634,9 @@ func TestListSnapshots_TimestampsAreUTCOnNonUTCHost(t *testing.T) {
// machine consumer would otherwise see no difference between "that // machine consumer would otherwise see no difference between "that
// snapshot is not on the destination" and "that snapshot could not be // snapshot is not on the destination" and "that snapshot could not be
// read". // read".
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) { func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t) env := newListEnv(t)
goodKey := env.addRemote(t, listRemoteID, goodKey := env.addRemote(t, listRemoteID,
@@ -650,16 +648,18 @@ func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) {
strings.NewReader("this is not a zstd stream")) strings.NewReader("this is not a zstd stream"))
require.NoError(t, err) require.NoError(t, err)
err = env.v.ListSnapshots(true) stderr := captureProcessStderr(t, func() {
require.NoError(t, err) require.NoError(t, env.v.ListSnapshots(true))
})
rows := decodeListJSON(t, env.stdout.String()) rows := decodeListJSON(t, env.stdout.String())
require.Len(t, rows, 1) require.Len(t, rows, 1)
assert.Equal(t, goodKey, rows[0].RemoteKey) assert.Equal(t, goodKey, rows[0].RemoteKey)
assert.Contains(t, env.stderr.String(), assert.Contains(t, stderr, "could not be described",
"1 remote snapshot(s) could not be described",
"a row dropped from the JSON document must be announced somewhere") "a row dropped from the JSON document must be announced somewhere")
assert.Contains(t, stderr, `"unreadable":1`,
"the count of dropped rows must be reported, not just the fact")
} }
// maxRemoteOnlyRowsForTest mirrors the maxRemoteOnlyRows cap in the // maxRemoteOnlyRowsForTest mirrors the maxRemoteOnlyRows cap in the
@@ -671,10 +671,9 @@ const maxRemoteOnlyRowsForTest = 1000
// truncation of a listing whose whole purpose is disaster recovery is // truncation of a listing whose whole purpose is disaster recovery is
// the wrong failure mode: the consumer least able to notice is exactly // the wrong failure mode: the consumer least able to notice is exactly
// the one reading JSON. // the one reading JSON.
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONReportsTruncation(t *testing.T) { func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t) env := newListEnv(t)
timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC) timestamp := time.Date(2026, 3, 2, 11, 22, 33, 0, time.UTC)
@@ -684,25 +683,30 @@ func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp) env.addRemote(t, fmt.Sprintf("otherhost_bulk_%04d", i), timestamp)
} }
err := env.v.ListSnapshots(true) stderr := captureProcessStderr(t, func() {
require.NoError(t, err) require.NoError(t, env.v.ListSnapshots(true))
})
rows := decodeListJSON(t, env.stdout.String()) rows := decodeListJSON(t, env.stdout.String())
assert.Len(t, rows, maxRemoteOnlyRowsForTest) assert.Len(t, rows, maxRemoteOnlyRowsForTest)
assert.Contains(t, env.stderr.String(), "listing truncated") assert.Contains(t, stderr, "Listing truncated")
assert.Contains(t, env.stderr.String(), "1 further remote-only") assert.Contains(t, stderr, `"omitted":1`)
assert.Contains(t, stderr,
fmt.Sprintf(`"limit":%d`, maxRemoteOnlyRowsForTest))
} }
// captureProcessStdout redirects the process's own stdout to a pipe, // captureProcessStdout redirects the process's own stdout to a pipe,
// rebuilds the global logger over it, runs fn, and returns everything // rebuilds the global logger, runs fn, and returns everything written to
// written. // the pipe.
// //
// internal/log builds its logger over os.Stdout at construction time and // The logger is rebuilt on purpose even though it is supposed to write
// offers no injectable sink (issue #82), so a warning logged during a // to stderr: that is exactly what makes this a regression guard. If the
// --json listing lands on the process's real stdout, not on any writer a // logger ever goes back to os.Stdout, Initialize picks up the pipe and
// test can inject. Capturing the file descriptor is therefore the only // the log record shows up in the capture, breaking the JSON parse here
// way a test can see what `snapshot list --json | jq` would see. // the same way it would break `snapshot list --json | jq` in the field.
// Without the rebuild, a regressed logger would write to the real stdout
// the test process was started with and go unnoticed.
// //
// Not parallel-safe: os.Stdout and the logger are process-global. // Not parallel-safe: os.Stdout and the logger are process-global.
func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string { func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
@@ -714,8 +718,6 @@ func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
previous := os.Stdout previous := os.Stdout
os.Stdout = writer os.Stdout = writer
// Rebuild the logger so it writes to the pipe rather than to the
// real stdout the test process was started with.
log.Initialize(log.Config{}) log.Initialize(log.Config{})
drained := make(chan string, 1) drained := make(chan string, 1)
@@ -738,7 +740,57 @@ func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
require.NoError(t, reader.Close()) require.NoError(t, reader.Close())
// Put the logger back on the restored stdout. // Put the logger back on the restored streams.
log.Initialize(log.Config{})
return captured
}
// captureProcessStderr redirects the process's own stderr to a pipe,
// rebuilds the global logger over it, runs fn, and returns everything
// written.
//
// internal/log writes every diagnostic to os.Stderr and captures that
// file at Initialize time, so a warning logged during a listing lands on
// the process's real stderr, not on any writer a test can inject.
// Capturing the file descriptor is therefore the only way a test can see
// what the operator would see. The captured stream is a pipe rather than
// a terminal, so the records are JSON — the same form a redirected
// stderr gets in production.
//
// Not parallel-safe: os.Stderr and the logger are process-global.
func captureProcessStderr(t *testing.T, fn func()) string {
t.Helper()
reader, writer, err := os.Pipe()
require.NoError(t, err)
previous := os.Stderr
os.Stderr = writer
log.Initialize(log.Config{})
drained := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, reader)
drained <- buf.String()
}()
fn()
os.Stderr = previous
require.NoError(t, writer.Close())
captured := <-drained
require.NoError(t, reader.Close())
// Put the logger back on the restored streams.
log.Initialize(log.Config{}) log.Initialize(log.Config{})
return captured return captured
@@ -747,13 +799,18 @@ func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
// TestListSnapshots_JSONStdoutIsOnlyTheDocument is the regression guard // TestListSnapshots_JSONStdoutIsOnlyTheDocument is the regression guard
// for `snapshot list --json | jq` surviving a damaged destination store. // for `snapshot list --json | jq` surviving a damaged destination store.
// //
// Every stdout writer the command has — the JSON encoder, the UI, and // Every stdout writer the command has — the JSON encoder and the UI
// the global logger — is pointed at one pipe here, exactly as they are // is pointed at one pipe here, exactly as they are pointed at one file
// pointed at one file descriptor in production. A single log line about // descriptor in production, and the logger is rebuilt over that same
// a corrupt manifest ahead of the array is enough to break the parse, // pipe's process-level stdout so that a logger which regressed back to
// and that is what this asserts cannot happen. // stdout would land in the capture. A single log line about a corrupt
// manifest ahead of the array is enough to break the parse, and that is
// what this asserts cannot happen.
// //
//nolint:paralleltest // replaces os.Stdout and the global logger // The two warnings are asserted on the separately captured stderr: they
// must be emitted, just not there.
//
//nolint:paralleltest // replaces os.Stdout, os.Stderr and the logger
func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) { func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
env := newListEnv(t) env := newListEnv(t)
@@ -772,12 +829,16 @@ func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
oddKey := env.addRemoteRawTimestamp(t, oddKey := env.addRemoteRawTimestamp(t,
"testhost_odd_2026-03-04T00:00:00Z", "the day before yesterday") "testhost_odd_2026-03-04T00:00:00Z", "the day before yesterday")
captured := captureProcessStdout(t, func(stdout io.Writer) { var captured string
stderr := captureProcessStderr(t, func() {
captured = captureProcessStdout(t, func(stdout io.Writer) {
env.v.Stdout = stdout env.v.Stdout = stdout
env.v.UI = ui.NewWithColor(stdout, false) env.v.UI = ui.NewWithColor(stdout, false)
require.NoError(t, env.v.ListSnapshots(true)) require.NoError(t, env.v.ListSnapshots(true))
}) })
})
rows := decodeListJSON(t, captured) rows := decodeListJSON(t, captured)
require.Len(t, rows, 2, "the readable snapshots must both be listed") require.Len(t, rows, 2, "the readable snapshots must both be listed")
@@ -794,8 +855,8 @@ func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
// Both warnings were emitted, on the stream that cannot corrupt the // Both warnings were emitted, on the stream that cannot corrupt the
// document. // document.
stderr := env.stderr.String()
assert.Contains(t, stderr, "Could not describe remote snapshot") assert.Contains(t, stderr, "Could not describe remote snapshot")
assert.Contains(t, stderr, "Remote manifest has an unparseable timestamp") assert.Contains(t, stderr, "Remote manifest has an unparseable timestamp")
assert.Contains(t, stderr, "1 remote snapshot(s) could not be described") assert.Contains(t, stderr, "could not be described")
assert.Contains(t, stderr, `"unreadable":1`)
} }

View File

@@ -43,7 +43,11 @@ type Vaultik struct {
ctx context.Context //nolint:containedctx // ctx bound at construction by design ctx context.Context //nolint:containedctx // ctx bound at construction by design
cancel context.CancelFunc cancel context.CancelFunc
// IO // IO. Stdout carries the output the user asked for and nothing else,
// so that `--json | jq` works. Stderr completes the standard triple
// for anything a command needs to write there directly; diagnostics
// are not that — they go through internal/log, which writes to the
// process's stderr.
Stdout io.Writer Stdout io.Writer
Stderr io.Writer Stderr io.Writer
Stdin io.Reader Stdin io.Reader