// 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 check everything short of contacting telegram provision plan --spec [--check] show what would change; writes nothing provision apply --spec execute an approved plan provision session bootstrap --campaign mint the operator session (interactive) provision session check --campaign 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 }