feat(veans): warn when Chain.Set falls back past a failed backend

A keyring transient failure on Set silently falls through to the file
backend today, which leaves a stale keyring entry from any prior
successful write shadowing the new file-backend token. Fixing the
shadow itself is deferred (would need a Set-and-Delete coordination,
or a stricter contract).

What we can do cheaply: surface the fallback so an operator hitting
the shadow has a breadcrumb. On Chain.Set fallthrough past a writable
backend that errored, print:

  veans: credential store: keyring rejected write (X); falling back to file

The warning goes to stderr (not the structured envelope — Set still
returns nil because the write landed somewhere). Env-backend's
read-only skip is unchanged and silent.

ChainStderr is exposed as a package var so tests can capture/assert
the warning when we backfill credential-store coverage.
This commit is contained in:
Tink bot 2026-05-26 15:38:43 +00:00 committed by kolaente
parent 75e546f0c1
commit ba6615f378
4 changed files with 78 additions and 27 deletions

View File

@ -99,11 +99,12 @@ jobs:
with:
go-version: stable
- name: Run unit tests
# The e2e package self-skips when VEANS_E2E_API_URL isn't set, so
# this job runs only the (fast) unit tests, independent of the
# heavier test-veans-e2e job that needs the API artifact.
# `-short` skips the e2e package via its TestMain (see
# veans/e2e/main_test.go), mirroring the parent monorepo's
# pkg/webtests convention. The heavier test-veans-e2e job
# runs the full suite against the api-build artifact.
working-directory: veans
run: go test -count=1 ./...
run: go test -count=1 -short ./...
check-translations:
runs-on: ubuntu-latest

View File

@ -534,18 +534,37 @@ func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID i
approve := opts.AutoApproveBuckets
if !approve {
fmt.Fprintf(opts.Out, "Missing canonical buckets: %s\n", strings.Join(missing, ", "))
ans, err := p.ReadLine("Bootstrap missing buckets? [Y/n/abort]: ")
const maxUnknownAnswers = 5
prompt := "Bootstrap missing buckets? [Y/n/abort]: "
unknown := 0
promptLoop:
for {
ans, err := p.ReadLine(prompt)
if err != nil {
return config.Buckets{}, err
}
ans = strings.ToLower(strings.TrimSpace(ans))
switch ans {
normalized := strings.ToLower(strings.TrimSpace(ans))
switch normalized {
case "", "y", "yes":
approve = true
break promptLoop
case "n", "no":
approve = false
return config.Buckets{}, output.New(output.CodeValidation,
"canonical buckets missing — either re-run `veans init` and answer Y to let veans bootstrap them, "+
"or create the missing buckets (%s) manually in Vikunja's UI and re-run `veans init`",
strings.Join(missing, ", "))
case "a", "abort":
return config.Buckets{}, output.New(output.CodeValidation, "user aborted bucket bootstrap")
default:
unknown++
if unknown > maxUnknownAnswers {
return config.Buckets{}, output.New(output.CodeValidation,
"could not understand bucket bootstrap answer after %d attempts — aborting; "+
"re-run `veans init` and answer y, n, or abort",
maxUnknownAnswers)
}
fmt.Fprintf(opts.Out, "didn't understand %q, please answer y or n (or abort)\n", ans)
}
}
}
if approve {
@ -579,7 +598,8 @@ func bootstrapBuckets(ctx context.Context, c *client.Client, projectID, viewID i
}
if out.Todo == 0 || out.InProgress == 0 || out.InReview == 0 || out.Done == 0 || out.Scrapped == 0 {
return config.Buckets{}, output.New(output.CodeValidation,
"canonical buckets missing — re-run with bucket bootstrap approved or create them manually")
"canonical buckets missing — either re-run `veans init` and let veans bootstrap them, "+
"or create the missing canonical buckets (Todo / In Progress / In Review / Done / Scrapped) manually in Vikunja's UI and re-run `veans init`")
}
return out, nil
}

View File

@ -22,8 +22,15 @@ package credentials
import (
"errors"
"fmt"
"io"
"os"
)
// ChainStderr is the writer the Chain uses for operator-visible warnings
// (currently: backend-fallthrough notices on Set). Tests override it; in
// production it points at os.Stderr.
var ChainStderr io.Writer = os.Stderr
// ErrNotFound is returned when no backend has the requested credential.
var ErrNotFound = errors.New("credential not found")
@ -67,17 +74,41 @@ func (c *Chain) Get(server, account string) (string, error) {
// transparently, falling through to the next — the file backend is the
// reliable last-resort. Only if every writable backend fails do we surface
// the last error.
//
// When a write fails on one writable backend and a later one succeeds, a
// single-line warning is printed to ChainStderr naming both backends.
// This is observability for the silent-shadow case: a stale keyring entry
// from a prior successful write can mask the freshly-written file token if
// keyring transiently rejects the new Set. The warning gives the operator
// a breadcrumb; Set itself still returns nil because the write landed
// somewhere durable.
func (c *Chain) Set(server, account, token string) error {
var lastErr error
var (
lastErr error
failedName string
failedErr error
)
for _, b := range c.Backends {
if _, ok := b.(*EnvBackend); ok {
continue
}
if err := b.Set(server, account, token); err == nil {
return nil
} else if !errors.Is(err, errReadOnly) {
lastErr = fmt.Errorf("%s: %w", b.Name(), err)
err := b.Set(server, account, token)
if err == nil {
if failedName != "" {
fmt.Fprintf(ChainStderr,
"veans: credential store: %s rejected write (%v); falling back to %s\n",
failedName, failedErr, b.Name())
}
return nil
}
if errors.Is(err, errReadOnly) {
continue
}
// Remember the most recent non-readonly failure so a later success
// can surface it, or so we can return it if every backend fails.
failedName = b.Name()
failedErr = err
lastErr = fmt.Errorf("%s: %w", b.Name(), err)
}
if lastErr != nil {
return lastErr

View File

@ -79,18 +79,17 @@ func (Test) Filter(expr string) error {
// E2E runs the e2e suite without `-short` so TestMain lets it through.
// Requires VEANS_E2E_API_URL to point at a running Vikunja instance and
// either VEANS_E2E_ADMIN_TOKEN or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS
// for the admin/seed identity.
// either VEANS_E2E_TESTING_TOKEN (matching the API's VIKUNJA_SERVICE_TESTINGTOKEN
// — the harness will seed its own admin via /api/v1/test/users) or
// VEANS_E2E_ADMIN_TOKEN (a pre-existing JWT for the admin to use as-is).
//
// Set VEANS_E2E_SKIP_BUILD=true to reuse a previously-built binary.
func (Test) E2E() error {
if os.Getenv("VEANS_E2E_API_URL") == "" {
return fmt.Errorf("VEANS_E2E_API_URL is not set — start a Vikunja instance and export the URL")
}
if os.Getenv("VEANS_E2E_ADMIN_TOKEN") == "" {
if os.Getenv("VEANS_E2E_ADMIN_USER") == "" || os.Getenv("VEANS_E2E_ADMIN_PASS") == "" {
return fmt.Errorf("set either VEANS_E2E_ADMIN_TOKEN or VEANS_E2E_ADMIN_USER + VEANS_E2E_ADMIN_PASS")
}
if os.Getenv("VEANS_E2E_ADMIN_TOKEN") == "" && os.Getenv("VEANS_E2E_TESTING_TOKEN") == "" {
return fmt.Errorf("set VEANS_E2E_ADMIN_TOKEN, or VEANS_E2E_TESTING_TOKEN (matching the API's VIKUNJA_SERVICE_TESTINGTOKEN) so the suite can seed its own admin")
}
if os.Getenv("VEANS_E2E_SKIP_BUILD") == "" {
if err := Build(); err != nil {