fluid-telegram/cmd/provision/preflight.go
tegwick de7a7bdd2a Read the standard token sink, and tell a 403 from an outage
NewFromEnv now falls back to ~/.vault-token, the file `bao login` writes and
the bao and vault CLIs already read. An operator who has logged in once
should not have to re-export a secret, and a token that never has to be
typed is a token that never lands in shell history.

Preflight distinguishes the two things a 403 means. An expired token and a
token missing a policy look identical in the error, and the check that
separates them -- whether `bao token lookup` also fails -- is worth naming
where it is read rather than left to be rediscovered.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0172sgCZEEDJcnQmr4SGDvKa

Assistant: claude-code
Assistant-Model: opus
Assistant-Process: 1361245@bnt-lap001
Assistant-Session: b3b428ef-f3e6-4688-b091-01f71461d66a
2026-09-04 22:27:01 +02:00

176 lines
5.6 KiB
Go

package main
import (
"context"
"errors"
"flag"
"fmt"
"os"
"strings"
"github.com/tegwick/fluid-telegram/internal/avatar"
"github.com/tegwick/fluid-telegram/internal/plan"
"github.com/tegwick/fluid-telegram/internal/secrets"
"github.com/tegwick/fluid-telegram/internal/spec"
"github.com/tegwick/fluid-telegram/internal/state"
tgc "github.com/tegwick/fluid-telegram/internal/tg"
)
// cmdPreflight exercises everything up to the point of contacting Telegram.
//
// It exists because the expensive failures in this tool all happen after a
// phone number has been spent and a conversation has started. Spec, OpenBao
// wiring, credentials, the avatar and the plan can all be wrong in ways that are
// free to discover beforehand, and this finds them in one run.
//
// It never writes and never contacts Telegram.
func cmdPreflight(args []string) error {
fs := flag.NewFlagSet("preflight", flag.ExitOnError)
specPath := fs.String("spec", "", "path to the presence spec")
root := fs.String("root", ".", "repo root holding presence/resolved/")
fs.Parse(args)
if *specPath == "" {
return errors.New("--spec is required")
}
ctx := context.Background()
var failed, warned int
ok := func(format string, a ...any) { fmt.Printf(" ok "+format+"\n", a...) }
warn := func(format string, a ...any) { warned++; fmt.Printf(" warn "+format+"\n", a...) }
bad := func(format string, a ...any) { failed++; fmt.Printf(" FAIL "+format+"\n", a...) }
fmt.Print("preflight -- nothing is written and telegram is not contacted\n\n")
// 1. The spec.
sp, digest, err := spec.Load(*specPath)
if err != nil {
bad("spec: %v", err)
return report(failed, warned)
}
ok("spec loads and validates (campaign %q, %s)", sp.Campaign, shortDigest(digest))
// 2. The avatar, before any conversation could be left half-finished.
switch img, err := avatar.Load(*specPath, sp.Bot.Avatar); {
case sp.Bot.Avatar == "":
warn("no avatar declared; the bot will have no picture")
case errors.Is(err, avatar.ErrMissing):
warn("avatar declared but missing: %s", sp.Bot.Avatar)
case err != nil:
bad("avatar: %v", err)
default:
ok("avatar %s (%dx%d, %d KiB)", sp.Bot.Avatar, img.Width, img.Height, img.Bytes/1024)
if note := img.CropNote(); note != "" {
warn("avatar %s", note)
}
}
// 3. OpenBao: configured, reachable, and the token actually works. A token
// that is merely present is not a token that can read.
store, err := secrets.NewFromEnv(sp.Campaign)
if err != nil {
bad("openbao: %v", err)
return report(failed, warned)
}
ok("openbao configured (%s)", os.Getenv("BAO_ADDR"))
appFields, found, err := store.Get(ctx, secrets.KeyOperatorApp)
switch {
case err != nil:
if strings.Contains(err.Error(), "403") {
bad("openbao rejected the token (403). If `bao token lookup` also fails,\n"+
" the token has expired -- run `bao login`. If lookup succeeds,\n"+
" the token lacks a policy for %s", store.Ref(secrets.KeyOperatorApp))
} else {
bad("openbao unreachable: %v", err)
}
case !found:
bad("no app credentials at %s\n write them with:\n"+
" bao kv put %s api_id=<n> api_hash=<hash>\n"+
" see docs/seeding-runbook.md step 2",
store.Ref(secrets.KeyOperatorApp), kvPath(store))
default:
if _, err := tgc.LoadCredentials(ctx, store); err != nil {
bad("app credentials are present but unusable: %v", err)
} else {
ok("app credentials readable (api_id %s...)", prefix(appFields["api_id"], 3))
}
}
// 4. The session. Absent is the expected state before the first bootstrap,
// so it is reported rather than failed.
if _, found, err := store.Get(ctx, secrets.KeyOperatorSession); err != nil {
bad("session check: %v", err)
} else if !found {
warn("no operator session yet -- run `provision session bootstrap --campaign %s`", sp.Campaign)
} else {
ok("operator session present (validity is only knowable by connecting)")
}
// 5. The salt, which must never be regenerated once it exists.
if _, found, err := store.Get(ctx, secrets.KeyRedactionSalt); err == nil {
if found {
ok("redaction salt already present; apply will not touch it")
} else {
ok("no redaction salt yet; apply will create one, once")
}
}
// 6. The plan that would result.
rs, err := state.Load(state.Path(*root, sp.Campaign))
if err != nil {
bad("resolved state: %v", err)
return report(failed, warned)
}
p, err := plan.Compute(*specPath, sp, digest, rs, offlineLive{})
if err != nil {
bad("plan: %v", err)
return report(failed, warned)
}
if p.Blocked() {
bad("the resulting plan is blocked")
} else {
ok("plan computes cleanly")
}
fmt.Printf("\n%s\n", indent(p.Render()))
fmt.Println("note: computed offline, so nothing was observed live.")
return report(failed, warned)
}
func report(failed, warned int) error {
fmt.Println()
if failed > 0 {
return fmt.Errorf("%d check(s) failed, %d warning(s)", failed, warned)
}
fmt.Printf("preflight passed with %d warning(s)\n", warned)
return nil
}
func kvPath(s *secrets.Store) string {
// Ref renders as bao:<mount>/<prefix>/<key>; the bao CLI wants it without
// the scheme, and without the KV v2 "data" segment the HTTP API needs.
return strings.TrimPrefix(s.Ref(secrets.KeyOperatorApp), "bao:")
}
func prefix(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n]
}
func indent(s string) string {
var b strings.Builder
for _, line := range strings.Split(strings.TrimRight(s, "\n"), "\n") {
b.WriteString(" " + line + "\n")
}
return b.String()
}
func shortDigest(d string) string {
d = strings.TrimPrefix(d, "sha256:")
if len(d) > 12 {
d = d[:12]
}
return "sha256:" + d
}