// 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 ( "flag" "fmt" "os" "path/filepath" "github.com/tegwick/fluid-telegram/internal/plan" "github.com/tegwick/fluid-telegram/internal/spec" "github.com/tegwick/fluid-telegram/internal/state" ) const usage = `provision -- reconcile a Telegram presence with its declared spec provision plan --spec [--check] show what would change; writes nothing provision apply --spec execute an approved plan session bootstrap / session check mint and inspect the operator session Flags: --spec path to the presence spec (campaign repo) --root repo root holding presence/resolved/ (default: cwd) --check exit non-zero if anything would change; for scheduled drift checks ` 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 = cmdPlan(os.Args[2:]) case "apply": err = fmt.Errorf("apply is not implemented yet (FT-WP-0002 T04); plan is safe to run") case "session": err = fmt.Errorf("session is not implemented yet (FT-WP-0002 T01)") 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 cmdPlan(args []string) error { fs := flag.NewFlagSet("plan", 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") fs.Parse(args) if *specPath == "" { return fmt.Errorf("--spec is required") } 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 } // Live observation needs the operator session, which does not exist yet. // Until it does, plan runs against the resolved state alone and says so -- // an offline plan is still worth reading on a first run, where everything is // a creation, but it must not be mistaken for a drift check. live, offline := liveOrOffline() p, err := plan.Compute(sp, digest, rs, live) if err != nil { return err } fmt.Print(p.Render()) if offline { fmt.Printf("\nnote: no operator session, so nothing was observed live.\n"+ " this plan reflects %s and the spec only.\n", relOrAbs(statePath)) if *check { return fmt.Errorf("--check needs live observation; see docs/seeding-runbook.md") } } if *check && !p.Empty() { return fmt.Errorf("presence has drifted from the spec") } // A block means the world disagrees with the state file; a deferral is the // design working. Only the first is a failure. if p.Blocked() { os.Exit(1) } return nil } // offlineLive answers only what can be known without a session. It never claims // something is fine; where it cannot tell, plan is told the presence is intact // so that a first run still renders, and the caller reports that it was offline. 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 } func liveOrOffline() (plan.Live, bool) { // FT-WP-0002 T03/T04: return the MTProto client once the session exists. return offlineLive{}, true } func relOrAbs(p string) string { if abs, err := filepath.Abs(p); err == nil { if wd, err := os.Getwd(); err == nil { if rel, err := filepath.Rel(wd, abs); err == nil { return rel } } } return p }