Author SHA1 Message Date
sneak b21996844f Honor NO_COLOR in the terminal log handler (closes #402)
check / check (push) Successful in 10m43s
The TTY log handler always wrote ANSI color, unlike internal/ui, which
disables color when NO_COLOR is set. Add a color flag to TTYHandler,
set from NO_COLOR with the same test internal/ui uses, and route every
color code through a paint helper that returns plain text when color is
off. The README's color sentence now covers log lines too.

Model: deepseek-v4-pro
2026-11-08 14:53:17 +00:00
6 changed files with 111 additions and 183 deletions

No files matched your search

+2 -3
View File
@@ -446,9 +446,6 @@ once the store is reachable to remove the snapshot's metadata from it
(`vaultik prune` does not). For a snapshot that is only on the (`vaultik prune` does not). For a snapshot that is only on the
destination store, that failure is an error. To wipe everything on the destination store, that failure is an error. To wipe everything on the
destination in one go, use `vaultik remote nuke --force`. destination in one go, use `vaultik remote nuke --force`.
* Accepts a snapshot ID, or — for a snapshot only on the destination
store — its remote key (or an unambiguous leading part of it) as shown
by `snapshot list`.
* `--local-only`: Skip remote cleanup; only touch the local index * `--local-only`: Skip remote cleanup; only touch the local index
* `--dry-run`: Show what would be deleted without deleting * `--dry-run`: Show what would be deleted without deleting
* `--force`: Skip confirmation prompt * `--force`: Skip confirmation prompt
@@ -839,6 +836,8 @@ Work planned after 1.0. Loosely ordered by priority.
Every command's user-facing output is governed by `internal/ui`, in one Every command's user-facing output is governed by `internal/ui`, in one
of two ways. Color is enabled when the stream written to is a TTY and of two ways. Color is enabled when the stream written to is a TTY and
the `NO_COLOR` environment variable is unset (https://no-color.org/). the `NO_COLOR` environment variable is unset (https://no-color.org/).
The same rule governs the colorized log lines `internal/log` writes to a
terminal.
* **Status, progress, warnings, and errors** go through the `internal/ui` * **Status, progress, warnings, and errors** go through the `internal/ui`
message methods below: marker-prefixed, colored on a TTY, and — except message methods below: marker-prefixed, colored on a TTY, and — except
-4
View File
@@ -288,10 +288,6 @@ know about), which is what 'vaultik prune' does. The command prints the
'vaultik prune' invocation to run as a follow-up only when it removed the 'vaultik prune' invocation to run as a follow-up only when it removed the
snapshot's metadata from the destination store, and never under --json. snapshot's metadata from the destination store, and never under --json.
The snapshot may be named by its ID or, on a host with no local index, by
the remote key that 'snapshot list' prints for a remote-only snapshot (an
unambiguous leading part is enough).
An ID that names no snapshot in the local index or on the destination An ID that names no snapshot in the local index or on the destination
store is an error. store is an error.
+32 -11
View File
@@ -5,6 +5,7 @@ import (
"fmt" "fmt"
"io" "io"
"log/slog" "log/slog"
"os"
"strconv" "strconv"
"strings" "strings"
"sync" "sync"
@@ -60,6 +61,12 @@ type TTYHandler struct {
mu *sync.Mutex mu *sync.Mutex
out io.Writer out io.Writer
// color reports whether records are written with ANSI color. It is
// false when NO_COLOR is set, matching internal/ui's shouldColor
// test; the caller already chose this handler for a terminal, so
// TTY-ness is not re-checked here.
color bool
// attrs are the attributes accumulated through WithAttrs, emitted // attrs are the attributes accumulated through WithAttrs, emitted
// ahead of each record's own attributes. Their keys already carry // ahead of each record's own attributes. Their keys already carry
// the group path that was open when they were added, so no // the group path that was open when they were added, so no
@@ -72,16 +79,19 @@ type TTYHandler struct {
groups []string groups []string
} }
// NewTTYHandler creates a new TTY handler with colored output. // NewTTYHandler creates a new TTY handler. Color is enabled unless the
// NO_COLOR environment variable is set to a non-empty value, the same
// test internal/ui applies to status lines.
func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler { func NewTTYHandler(out io.Writer, opts *slog.HandlerOptions) *TTYHandler {
if opts == nil { if opts == nil {
opts = &slog.HandlerOptions{} opts = &slog.HandlerOptions{}
} }
return &TTYHandler{ return &TTYHandler{
out: out, out: out,
opts: *opts, opts: *opts,
mu: &sync.Mutex{}, mu: &sync.Mutex{},
color: os.Getenv("NO_COLOR") == "",
} }
} }
@@ -124,10 +134,10 @@ func (h *TTYHandler) Handle(_ context.Context, r slog.Record) error {
// are written around it: it can carry text from an untrusted source // are written around it: it can carry text from an untrusted source
// (a storage error, for one), and a raw control character would // (a storage error, for one), and a raw control character would
// otherwise reach the terminal. // otherwise reach the terminal.
_, _ = fmt.Fprintf(h.out, "%s%s%s %s%s%s %s%s%s", _, _ = fmt.Fprintf(h.out, "%s %s %s",
colorGray, timestamp, colorReset, h.paint(colorGray, timestamp),
levelColor, level, colorReset, h.paint(levelColor, level),
colorBold, sanitize(r.Message), colorReset) h.paint(colorBold, sanitize(r.Message)))
// Attributes carried by the handler come first, then the record's // Attributes carried by the handler come first, then the record's
// own. Handler attributes were qualified when they were added; the // own. Handler attributes were qualified when they were added; the
@@ -236,6 +246,7 @@ func (h *TTYHandler) clone() *TTYHandler {
opts: h.opts, opts: h.opts,
mu: h.mu, mu: h.mu,
out: h.out, out: h.out,
color: h.color,
attrs: make([]slog.Attr, len(h.attrs), len(h.attrs)+1), attrs: make([]slog.Attr, len(h.attrs), len(h.attrs)+1),
groups: make([]string, len(h.groups), len(h.groups)+1), groups: make([]string, len(h.groups), len(h.groups)+1),
} }
@@ -273,9 +284,19 @@ func (h *TTYHandler) writeAttr(a slog.Attr) {
// the snapshot database — so a control character in one of them must // the snapshot database — so a control character in one of them must
// be rendered as an escape sequence rather than reaching the terminal, // be rendered as an escape sequence rather than reaching the terminal,
// where it could move the cursor or inject its own colours. // where it could move the cursor or inject its own colours.
_, _ = fmt.Fprintf(h.out, " %s%s%s=%s%s%s", _, _ = fmt.Fprintf(h.out, " %s=%s",
colorCyan, sanitize(a.Key), colorReset, h.paint(colorCyan, sanitize(a.Key)),
colorBlue, sanitize(value), colorReset) h.paint(colorBlue, sanitize(value)))
}
// paint wraps s in the given ANSI color when color is enabled, and
// returns s unchanged otherwise.
func (h *TTYHandler) paint(color, s string) string {
if !h.color {
return s
}
return color + s + colorReset
} }
// sanitize returns s unchanged when every rune in it is printable, and a // sanitize returns s unchanged when every rune in it is printable, and a
+41
View File
@@ -421,3 +421,44 @@ func TestTTYHandlerEmptyGroupAndAttrsAreNoOps(t *testing.T) {
assert.Contains(t, plain, "kept=yes") assert.Contains(t, plain, "kept=yes")
assert.NotContains(t, plain, " =") assert.NotContains(t, plain, " =")
} }
// TestTTYHandlerRespectsNoColor checks that only a non-empty NO_COLOR
// turns color off, matching internal/ui's shouldColor test. The handler
// is only ever chosen for a terminal, so this is the one remaining
// condition that can still leave color in log output.
func TestTTYHandlerRespectsNoColor(t *testing.T) {
cases := []struct {
name string
noColor string
colored bool
}{
{
name: "NO_COLOR turns color off",
noColor: "1",
},
{
name: "empty NO_COLOR keeps color on",
noColor: "",
colored: true,
},
}
for _, testCase := range cases {
t.Run(testCase.name, func(t *testing.T) {
t.Setenv("NO_COLOR", testCase.noColor)
var buf bytes.Buffer
logger := slog.New(log.NewTTYHandler(&buf, debugHandlerOptions()))
logger.Info("hello", "key", "value")
if testCase.colored {
assert.Contains(t, buf.String(), "\x1b[",
"an empty NO_COLOR must leave ANSI color in log output")
} else {
assert.NotContains(t, buf.String(), "\x1b[",
"NO_COLOR must suppress every ANSI escape in log output")
}
})
}
}
-94
View File
@@ -451,100 +451,6 @@ func TestRemoveSnapshot_NotInLocalIndex(t *testing.T) {
assert.Contains(t, out, "Removed snapshot metadata from remote storage") assert.Contains(t, out, "Removed snapshot metadata from remote storage")
} }
// TestRemoveSnapshot_RemoteOnlyByRemoteKey checks that a snapshot only
// on the destination store, on a host with no local index, is removed by
// the remote key that 'snapshot list' prints for such a snapshot, and by
// an unambiguous leading part of it — the same identifiers verify and
// restore accept. Snapshot remove used to hash the argument again, so
// the hashed key never matched and only the plain snapshot ID worked.
func TestRemoveSnapshot_RemoteOnlyByRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
remoteKey := snapshot.RemoteSnapshotKey("snapshot-001")
identifiers := map[string]string{
"full key": remoteKey,
"leading part": remoteKey[:8],
}
for name, identifier := range identifiers {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// No local index: a host that sees the snapshot only remotely.
tv := vaultik.NewForTesting(store)
tv.Repositories = newLocalIndex(t)
result, err := tv.RemoveSnapshot(identifier,
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.True(t, result.RemoteRemoved)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
out := tv.Stdout.String()
assert.NotContains(t, out, "from local database")
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
})
}
}
// TestRemoveSnapshot_LocalIndexByRemoteKey checks that a remote key (or
// its unambiguous leading part) that names a snapshot the local index
// holds removes that snapshot's local row and its remote metadata
// together. `snapshot list --json` prints a remote key for every
// snapshot, including local ones.
func TestRemoveSnapshot_LocalIndexByRemoteKey(t *testing.T) {
log.Initialize(log.Config{})
t.Parallel()
remoteKey := snapshot.RemoteSnapshotKey("snapshot-001")
identifiers := map[string]string{
"full key": remoteKey,
"leading part": remoteKey[:8],
}
for name, identifier := range identifiers {
t.Run(name, func(t *testing.T) {
t.Parallel()
store := newTestStorer()
addManifest(t, store, "snapshot-001", nil)
// A backup wrote to this destination, so it carries the
// marker, and the local index holds the snapshot.
require.NoError(t, store.Put(context.Background(), markerFile,
bytes.NewReader(nil)))
tv := vaultik.NewForTesting(store)
repos := newLocalIndex(t, "snapshot-001")
tv.Repositories = repos
result, err := tv.RemoveSnapshot(identifier,
&vaultik.RemoveOptions{Force: true})
require.NoError(t, err)
assert.True(t, result.RemoteRemoved)
assert.False(t, store.hasKey(remoteKeyPath("snapshot-001")))
row, err := repos.Snapshots.GetByID(
context.Background(), "snapshot-001")
require.NoError(t, err)
assert.Nil(t, row)
out := tv.Stdout.String()
assert.Contains(t, out,
"Removed snapshot 'snapshot-001' from local database")
assert.Contains(t, out, "Removed snapshot metadata from remote storage")
})
}
}
// TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a // TestRemoveSnapshot_NotInLocalIndexDeleteFails checks that removing a
// snapshot only the destination store holds fails when its metadata // snapshot only the destination store holds fails when its metadata
// cannot be deleted there, under --force and under --json: nothing was // cannot be deleted there, under --force and under --json: nothing was
+36 -71
View File
@@ -1480,7 +1480,7 @@ func (v *Vaultik) RemoveSnapshot(
return result, err return result, err
} }
inLocalIndex, localID, remoteKey, err := v.findSnapshotToRemove(snapshotID, opts) inLocalIndex, err := v.findSnapshotToRemove(snapshotID, opts)
if err != nil { if err != nil {
return result, err return result, err
} }
@@ -1502,16 +1502,16 @@ func (v *Vaultik) RemoveSnapshot(
} }
if inLocalIndex { if inLocalIndex {
log.Info("Removing snapshot from local database", "snapshot_id", localID) log.Info("Removing snapshot from local database", "snapshot_id", snapshotID)
err = v.deleteSnapshotFromLocalDB(localID) err = v.deleteSnapshotFromLocalDB(snapshotID)
if err != nil { if err != nil {
return result, fmt.Errorf("removing from local database: %w", err) return result, fmt.Errorf("removing from local database: %w", err)
} }
} }
result.RemoteRemoved, err = v.removeSnapshotRemote( result.RemoteRemoved, err = v.removeSnapshotRemote(
snapshotID, remoteKey, inLocalIndex, markerErr, opts) snapshotID, inLocalIndex, markerErr, opts)
if err != nil { if err != nil {
return result, err return result, err
} }
@@ -1530,7 +1530,7 @@ func (v *Vaultik) RemoveSnapshot(
if inLocalIndex { if inLocalIndex {
v.UI.Infof("Removed snapshot '%s' from local database.", v.UI.Infof("Removed snapshot '%s' from local database.",
v.UI.Snapshot(localID)) v.UI.Snapshot(snapshotID))
} }
if result.RemoteRemoved { if result.RemoteRemoved {
@@ -1543,68 +1543,50 @@ func (v *Vaultik) RemoveSnapshot(
return result, nil return result, nil
} }
// findSnapshotToRemove resolves snapshotID, the identifier remove was // findSnapshotToRemove reports whether the local index has a row for
// given, and returns whether the local index holds that snapshot, the // snapshotID, and returns errSnapshotNotFound when there is nothing to
// human ID of its row when it does, and the remote key to strip from // remove: no row and, unless LocalOnly is set, no metadata on the
// the destination store. A human ID is looked up in the local index // destination store either. The destination store is consulted only
// literally and hashed without consulting the store; a remote key, or // when the local index has no row, so a snapshot in the local index can
// an unambiguous leading part of one, is resolved against the store's // still be removed from it while the destination store is unreachable,
// metadata listing the way verify and restore do, and then matched back // and only after checkDestinationMarker passes, so an unplugged volume's
// to the local row whose hashed ID equals it, so removing by such a key // empty mount point is not taken for a store without the snapshot.
// removes the local row too. The destination store is consulted only
// after checkDestinationMarker, so an unplugged volume's empty mount
// point is not taken for a store without the snapshot. The identifier
// naming nothing, on the local index or, unless LocalOnly is set, on
// the store, is errSnapshotNotFound; a remote-key form that matches
// nothing or more than one directory surfaces
// resolveSnapshotRemoteKey's errSnapshotKeyNotFound or
// errSnapshotKeyAmbiguous.
func (v *Vaultik) findSnapshotToRemove( func (v *Vaultik) findSnapshotToRemove(
snapshotID string, opts *RemoveOptions, snapshotID string, opts *RemoveOptions,
) (bool, string, string, error) { ) (bool, error) {
if v.Repositories != nil { if v.Repositories != nil {
snap, err := v.Repositories.Snapshots.GetByID(v.ctx, snapshotID) snap, err := v.Repositories.Snapshots.GetByID(v.ctx, snapshotID)
if err != nil { if err != nil {
return false, "", "", fmt.Errorf("looking up snapshot in local index: %w", err) return false, fmt.Errorf("looking up snapshot in local index: %w", err)
} }
if snap != nil { if snap != nil {
return true, snapshotID, snapshot.RemoteSnapshotKey(snapshotID), nil return true, nil
} }
} }
if opts.LocalOnly { if opts.LocalOnly {
return false, "", "", fmt.Errorf("%w in the local index: %s", return false, fmt.Errorf("%w in the local index: %s",
errSnapshotNotFound, snapshotID) errSnapshotNotFound, snapshotID)
} }
err := v.checkDestinationMarker() err := v.checkDestinationMarker()
if err != nil { if err != nil {
return false, "", "", err return false, err
} }
remoteKey, err := v.resolveSnapshotRemoteKey(snapshotID) objects, err := v.remoteSnapshotObjects(snapshot.RemoteSnapshotKey(snapshotID))
if err != nil { if err != nil {
return false, "", "", err return false, fmt.Errorf("looking up snapshot on destination store: %w", err)
}
objects, err := v.remoteSnapshotObjects(remoteKey)
if err != nil {
return false, "", "", fmt.Errorf("looking up snapshot on destination store: %w", err)
} }
if len(objects) == 0 { if len(objects) == 0 {
return false, "", "", fmt.Errorf( return false, fmt.Errorf(
"%w in the local index or on the destination store: %s", "%w in the local index or on the destination store: %s",
errSnapshotNotFound, snapshotID) errSnapshotNotFound, snapshotID)
} }
localID, err := v.localSnapshotIDForRemoteKey(remoteKey) return false, nil
if err != nil {
return false, "", "", err
}
return localID != "", localID, remoteKey, nil
} }
// removeSnapshotDryRun reports what RemoveSnapshot would do without // removeSnapshotDryRun reports what RemoveSnapshot would do without
@@ -1667,20 +1649,19 @@ func (v *Vaultik) confirmRemoveSnapshot(snapshotID string, opts *RemoveOptions)
} }
// removeSnapshotRemote strips the snapshot's metadata from the // removeSnapshotRemote strips the snapshot's metadata from the
// destination store. remoteKey is the resolved metadata directory name // destination store. It returns true only when the delete finished
// to delete, as returned by findSnapshotToRemove. It returns true only // without an error and removed at least one object; under LocalOnly it
// when the delete finished without an error and removed at least one // does nothing. markerErr is the result of checkDestinationMarker; when
// object; under LocalOnly it does nothing. markerErr is the result of // it is set, nothing is deleted and it is the failure. When the snapshot
// checkDestinationMarker; when it is set, nothing is deleted and it is // is only on the destination store, a failure is returned as an error,
// the failure. When the snapshot is only on the destination store, a // because that removal is all the command does. When the snapshot was in
// failure is returned as an error, because that removal is all the // the local index, its removal there has already happened, so a failure
// command does. When the snapshot was in the local index, its removal // is only a warning telling the user to run `vaultik snapshot remove`
// there has already happened, so a failure is only a warning telling // for the snapshot again once the destination store is reachable
// the user to run `vaultik snapshot remove` for the snapshot again once // (`vaultik prune` never removes snapshot metadata that has its
// the destination store is reachable (`vaultik prune` never removes // manifest).
// snapshot metadata that has its manifest).
func (v *Vaultik) removeSnapshotRemote( func (v *Vaultik) removeSnapshotRemote(
snapshotID, remoteKey string, inLocalIndex bool, markerErr error, snapshotID string, inLocalIndex bool, markerErr error,
opts *RemoveOptions, opts *RemoveOptions,
) (bool, error) { ) (bool, error) {
if opts.LocalOnly { if opts.LocalOnly {
@@ -1694,7 +1675,8 @@ func (v *Vaultik) removeSnapshotRemote(
err := markerErr err := markerErr
if err == nil { if err == nil {
deleted, err = v.deleteRemoteSnapshotByKey(remoteKey) deleted, err = v.deleteRemoteSnapshotByKey(
snapshot.RemoteSnapshotKey(snapshotID))
} }
if err != nil && !inLocalIndex { if err != nil && !inLocalIndex {
@@ -1808,23 +1790,6 @@ func (v *Vaultik) localSnapshotIDs() ([]string, error) {
return ids, nil return ids, nil
} }
// localSnapshotIDForRemoteKey returns the human snapshot ID of the local
// index row whose RemoteSnapshotKey is key, or "" when no row has it.
func (v *Vaultik) localSnapshotIDForRemoteKey(key string) (string, error) {
ids, err := v.localSnapshotIDs()
if err != nil {
return "", fmt.Errorf("listing local snapshots: %w", err)
}
for _, id := range ids {
if snapshot.RemoteSnapshotKey(id) == key {
return id, nil
}
}
return "", nil
}
// listAllRemoteSnapshotKeys collects the hashed remote keys // listAllRemoteSnapshotKeys collects the hashed remote keys
// (subdirectories under metadata/) currently present in the // (subdirectories under metadata/) currently present in the
// destination store. Returns (nil, err) when the store cannot be // destination store. Returns (nil, err) when the store cannot be