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
167 lines
5 KiB
Go
167 lines
5 KiB
Go
// Command provision reconciles a Telegram presence with its declared
|
|
// specification.
|
|
//
|
|
// It is the provisioning plane, and it is deliberately separate from the
|
|
// adapter: the adapter holds a bot token with post_messages and nothing else,
|
|
// and has no code path into here. A compromised adapter cannot create, rename,
|
|
// delete or re-permission anything.
|
|
//
|
|
// See docs/provisioning.md for the design and docs/seeding-runbook.md for the
|
|
// steps that precede the first run.
|
|
package main
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
|
|
"github.com/tegwick/fluid-telegram/internal/apply"
|
|
"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"
|
|
)
|
|
|
|
const usage = `provision -- reconcile a Telegram presence with its declared spec
|
|
|
|
provision preflight --spec <path> check everything short of contacting telegram
|
|
provision plan --spec <path> [--check] show what would change; writes nothing
|
|
provision apply --spec <path> execute an approved plan
|
|
|
|
provision session bootstrap --campaign <slug> mint the operator session (interactive)
|
|
provision session check --campaign <slug> verify the stored session
|
|
|
|
Flags:
|
|
--spec path to the presence spec (in the campaign repo)
|
|
--root repo root holding presence/resolved/ (default: .)
|
|
--check exit non-zero if anything would change; for scheduled drift checks
|
|
--offline compute a plan without contacting Telegram
|
|
|
|
Credentials come from OpenBao via BAO_ADDR and BAO_TOKEN.
|
|
See docs/seeding-runbook.md.
|
|
`
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Fprint(os.Stderr, usage)
|
|
os.Exit(2)
|
|
}
|
|
var err error
|
|
switch os.Args[1] {
|
|
case "plan":
|
|
err = cmdReconcile(os.Args[2:], false)
|
|
case "apply":
|
|
err = cmdReconcile(os.Args[2:], true)
|
|
case "preflight":
|
|
err = cmdPreflight(os.Args[2:])
|
|
case "session":
|
|
err = cmdSession(os.Args[2:])
|
|
case "-h", "--help", "help":
|
|
fmt.Print(usage)
|
|
return
|
|
default:
|
|
err = fmt.Errorf("unknown command %q", os.Args[1])
|
|
}
|
|
if err != nil {
|
|
fmt.Fprintln(os.Stderr, "provision:", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func cmdReconcile(args []string, doApply bool) error {
|
|
name := "plan"
|
|
if doApply {
|
|
name = "apply"
|
|
}
|
|
fs := flag.NewFlagSet(name, flag.ExitOnError)
|
|
specPath := fs.String("spec", "", "path to the presence spec")
|
|
root := fs.String("root", ".", "repo root holding presence/resolved/")
|
|
check := fs.Bool("check", false, "exit non-zero if anything would change")
|
|
offline := fs.Bool("offline", false, "compute a plan without contacting Telegram")
|
|
fs.Parse(args)
|
|
|
|
if *specPath == "" {
|
|
return errors.New("--spec is required")
|
|
}
|
|
if doApply && *offline {
|
|
return errors.New("--offline cannot be combined with apply")
|
|
}
|
|
|
|
sp, digest, err := spec.Load(*specPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
statePath := state.Path(*root, sp.Campaign)
|
|
rs, err := state.Load(statePath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
ctx := context.Background()
|
|
|
|
if *offline {
|
|
p, err := plan.Compute(*specPath, sp, digest, rs, offlineLive{})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Print(p.Render())
|
|
fmt.Printf("\nnote: --offline, so nothing was observed live. this plan reflects\n"+
|
|
" %s and the spec only, and is not a drift check.\n", statePath)
|
|
if *check {
|
|
return errors.New("--check needs live observation; drop --offline")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
store, err := secrets.NewFromEnv(sp.Campaign)
|
|
if err != nil {
|
|
return fmt.Errorf("%w\n(use --offline for a plan that does not contact Telegram)", err)
|
|
}
|
|
creds, err := tgc.LoadCredentials(ctx, store)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
client := tgc.New(store, creds)
|
|
|
|
// No authenticator: reconciliation must never prompt. If the session is
|
|
// gone, that is a runbook step, not something to paper over mid-run.
|
|
return client.Run(ctx, nil, func(ctx context.Context, c *tgc.Client) error {
|
|
p, err := plan.Compute(*specPath, sp, digest, rs, tgc.Live{Ctx: ctx, Client: c, Resolved: rs})
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Print(p.Render())
|
|
|
|
if *check && !p.Empty() {
|
|
return errors.New("presence has drifted from the spec")
|
|
}
|
|
if p.Blocked() {
|
|
os.Exit(1)
|
|
}
|
|
if !doApply {
|
|
return nil
|
|
}
|
|
if p.Empty() {
|
|
fmt.Println("\nnothing to apply")
|
|
return nil
|
|
}
|
|
fmt.Println("\napplying:")
|
|
return apply.Run(ctx, p, apply.Options{
|
|
Spec: sp, SpecDigest: digest, State: rs, StatePath: statePath,
|
|
Store: store, Client: c, Out: os.Stdout,
|
|
})
|
|
})
|
|
}
|
|
|
|
// offlineLive answers only what can be known without a session. It never claims
|
|
// something is fine; where it cannot tell, it reports the presence intact so a
|
|
// first run still renders, and the caller says plainly that nothing was observed.
|
|
type offlineLive struct{}
|
|
|
|
func (offlineLive) BotExists(string) (bool, error) { return true, nil }
|
|
func (offlineLive) ChannelAdminRights(int64) ([]string, error) {
|
|
return []string{spec.PostMessages}, nil
|
|
}
|
|
func (offlineLive) ChannelUsername(int64) (string, error) { return "", nil }
|