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= api_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://; 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 }