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
176 lines
5.6 KiB
Go
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
|
|
}
|