The avatar is now applied rather than deferred: BotFather's /setuserpic is a conversation in which you send a photo, so the file is uploaded and sent as a message. It is content addressed -- replacing the file is what triggers an update, and the digest is recorded only after BotFather confirms, so a failed upload retries rather than being remembered as done. The image is validated before the conversation starts, because an image rejected halfway leaves the bot registered without a picture. Adds `provision preflight`: spec, avatar, OpenBao reachability, credentials, session presence, salt and the resulting plan, checked in one run that writes nothing and never contacts Telegram. Every failure it reports is one that would otherwise surface after a phone number had been spent. Two bugs it found immediately. The avatar path is documented as repo-relative but resolved against the spec's own directory, so the real campaign spec failed to find its own asset. And the OpenBao error named both variables when only one was missing, sending the reader to check the one already set. 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
170 lines
5.3 KiB
Go
170 lines
5.3 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:
|
|
bad("openbao unreachable or token rejected: %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
|
|
}
|