Log to stderr and stop discarding With attributes (closes #82)
check / check (push) Successful in 4m20s

Closes #97.

internal/log attached both handlers to os.Stdout, so any record that was
not suppressed landed in the middle of a --json document. WARN and ERROR
are never suppressed, so this was not hypothetical: a config file with
permissions looser than 0600 was enough to break
`vaultik snapshot list --json | jq`.

Both handlers now write to os.Stderr, and the TTY-vs-JSON format choice
tests os.Stderr rather than os.Stdout - the format has to follow the
stream the records land on, or a redirected stderr gets colorized
whenever stdout happens to be a terminal.

User-visible: --verbose and --debug output moves to stderr too, so
`vaultik snapshot list -v > out.txt` no longer captures diagnostics.
--quiet and --cron semantics are unchanged.

TTYHandler.WithAttrs and WithGroup discarded their arguments and returned
the receiver, while their doc comments claimed otherwise, so attributes
passed through the exported log.With vanished. The effect was
environment-dependent in the worst direction: handler choice is by
TTY-ness, so attributes disappeared on a terminal - where a developer is
debugging - and appeared correctly in CI. Both now return a new handler
with copied state rather than mutating the receiver, since slog permits a
handler to be shared and derived from concurrently. A test asserts the
TTY and JSON handlers emit the same attribute set, which is the test that
would have caught the original defect.

The local workaround in snapshot_list.go is removed now that the logger
no longer writes to stdout. The collect-then-emit machinery is kept, but
for a different reason than it was added: emitting from the fetch workers
would order warnings by network timing, whereas key-order emission after
group.Wait() is deterministic run to run.

Not yet complete: --json stdout still carries the startup banner, which
internal/cli/entry.go writes before cobra parses and which
bannerSuppressedInArgs does not recognise --json for. That is the
remaining stdout contamination path and is tracked in #106.
This commit was merged in pull request #107.
This commit is contained in:
clawbot committed 2026-08-09 18:43:55 +02:00
1 parent e3f407b440
commit c16ef476a9
9 files changed
+841 -162

No files matched your search

+105 -44
View File
@@ -521,16 +521,16 @@ func TestListSnapshots_JSONMergedView(t *testing.T) {
// TestListSnapshots_JSONUnreachableRemote checks that a failed listing
// does not corrupt the JSON document with warning text, and that
// "unknown" is reported as null rather than as absence.
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONUnreachableRemote(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
env.addLocal(t, listLocalID, time.Date(2026, 3, 1, 10, 0, 0, 0, time.UTC))
env.store.listErr = errRemoteUnreachable
err := env.v.ListSnapshots(true)
require.NoError(t, err)
stderr := captureProcessStderr(t, func() {
require.NoError(t, env.v.ListSnapshots(true))
})
// stdout must be nothing but the JSON document, so the warning has
// to go to stderr.
@@ -542,9 +542,8 @@ func TestListSnapshots_JSONUnreachableRemote(t *testing.T) {
assert.Nil(t, rows[0].RemotePresent,
"remote state is unknown when the destination cannot be listed")
assert.Contains(t, env.stderr.String(),
"could not list backup destination store")
assert.Contains(t, env.stderr.String(), "permission denied")
assert.Contains(t, stderr, "Could not list backup destination store")
assert.Contains(t, stderr, "permission denied")
}
// 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
// snapshot is not on the destination" and "that snapshot could not be
// read".
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
goodKey := env.addRemote(t, listRemoteID,
@@ -650,16 +648,18 @@ func TestListSnapshots_JSONReportsUnreadableManifests(t *testing.T) {
strings.NewReader("this is not a zstd stream"))
require.NoError(t, err)
err = env.v.ListSnapshots(true)
require.NoError(t, err)
stderr := captureProcessStderr(t, func() {
require.NoError(t, env.v.ListSnapshots(true))
})
rows := decodeListJSON(t, env.stdout.String())
require.Len(t, rows, 1)
assert.Equal(t, goodKey, rows[0].RemoteKey)
assert.Contains(t, env.stderr.String(),
"1 remote snapshot(s) could not be described",
assert.Contains(t, stderr, "could not be described",
"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
@@ -671,10 +671,9 @@ const maxRemoteOnlyRowsForTest = 1000
// truncation of a listing whose whole purpose is disaster recovery is
// the wrong failure mode: the consumer least able to notice is exactly
// the one reading JSON.
//
//nolint:paralleltest // captureProcessStderr replaces os.Stderr
func TestListSnapshots_JSONReportsTruncation(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
env := newListEnv(t)
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)
}
err := env.v.ListSnapshots(true)
require.NoError(t, err)
stderr := captureProcessStderr(t, func() {
require.NoError(t, env.v.ListSnapshots(true))
})
rows := decodeListJSON(t, env.stdout.String())
assert.Len(t, rows, maxRemoteOnlyRowsForTest)
assert.Contains(t, env.stderr.String(), "listing truncated")
assert.Contains(t, env.stderr.String(), "1 further remote-only")
assert.Contains(t, stderr, "Listing truncated")
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,
// rebuilds the global logger over it, runs fn, and returns everything
// written.
// rebuilds the global logger, runs fn, and returns everything written to
// the pipe.
//
// internal/log builds its logger over os.Stdout at construction time and
// offers no injectable sink (issue #82), so a warning logged during a
// --json listing lands on the process's real stdout, not on any writer a
// test can inject. Capturing the file descriptor is therefore the only
// way a test can see what `snapshot list --json | jq` would see.
// The logger is rebuilt on purpose even though it is supposed to write
// to stderr: that is exactly what makes this a regression guard. If the
// logger ever goes back to os.Stdout, Initialize picks up the pipe and
// the log record shows up in the capture, breaking the JSON parse here
// 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.
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
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{})
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())
// 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{})
return captured
@@ -747,13 +799,18 @@ func captureProcessStdout(t *testing.T, fn func(stdout io.Writer)) string {
// TestListSnapshots_JSONStdoutIsOnlyTheDocument is the regression guard
// for `snapshot list --json | jq` surviving a damaged destination store.
//
// Every stdout writer the command has — the JSON encoder, the UI, and
// the global logger — is pointed at one pipe here, exactly as they are
// pointed at one file descriptor in production. 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.
// Every stdout writer the command has — the JSON encoder and the UI —
// is pointed at one pipe here, exactly as they are pointed at one file
// descriptor in production, and the logger is rebuilt over that same
// pipe's process-level stdout so that a logger which regressed back to
// 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) {
env := newListEnv(t)
@@ -772,11 +829,15 @@ func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
oddKey := env.addRemoteRawTimestamp(t,
"testhost_odd_2026-03-04T00:00:00Z", "the day before yesterday")
captured := captureProcessStdout(t, func(stdout io.Writer) {
env.v.Stdout = stdout
env.v.UI = ui.NewWithColor(stdout, false)
var captured string
require.NoError(t, env.v.ListSnapshots(true))
stderr := captureProcessStderr(t, func() {
captured = captureProcessStdout(t, func(stdout io.Writer) {
env.v.Stdout = stdout
env.v.UI = ui.NewWithColor(stdout, false)
require.NoError(t, env.v.ListSnapshots(true))
})
})
rows := decodeListJSON(t, captured)
@@ -794,8 +855,8 @@ func TestListSnapshots_JSONStdoutIsOnlyTheDocument(t *testing.T) {
// Both warnings were emitted, on the stream that cannot corrupt the
// document.
stderr := env.stderr.String()
assert.Contains(t, stderr, "Could not describe remote snapshot")
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`)
}