plan --check treated BotFather profile reassertion as drift, so a scheduled check could never go quiet. Reassertion is now its own kind; a changed spec digest is the drift signal. apply reasserts the profile, updates channel copy when the spec changes, and is tested against a fake Telegram surface. The adapter reads its chat id from resolved state and its token from OpenBao, never the operator session. IEI-2 records that an approved provisioning plan is not autonomous Daimon action. Assistant: grok Assistant-Session: 01a09d5a-92c1-7802-915b-92bee57fc9f0
197 lines
6.1 KiB
Go
197 lines
6.1 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/adaptercfg"
|
|
"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 adapter-config --spec <path> --channel test|public
|
|
print the adapter's non-secret configuration
|
|
|
|
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 "adapter-config":
|
|
err = cmdAdapterConfig(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.Drifted() {
|
|
return errors.New("presence has drifted from the spec")
|
|
}
|
|
if p.Blocked() {
|
|
return errors.New("plan is blocked; nothing applied")
|
|
}
|
|
if !doApply {
|
|
return nil
|
|
}
|
|
if !p.NeedsApply() {
|
|
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, Surface: apply.Live(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 }
|
|
|
|
func cmdAdapterConfig(args []string) error {
|
|
fs := flag.NewFlagSet("adapter-config", flag.ExitOnError)
|
|
specPath := fs.String("spec", "", "path to the presence spec")
|
|
root := fs.String("root", ".", "repo root holding presence/resolved/")
|
|
channel := fs.String("channel", "", "which channel this adapter instance publishes to (test or public)")
|
|
fs.Parse(args)
|
|
if *specPath == "" {
|
|
return errors.New("--spec is required")
|
|
}
|
|
sp, _, err := spec.Load(*specPath)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
rs, err := state.Load(state.Path(*root, sp.Campaign))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
cfg, err := adaptercfg.FromResolved(rs, *channel, secrets.RefFor(sp.Campaign, secrets.KeyBotToken))
|
|
if err != nil {
|
|
return err
|
|
}
|
|
fmt.Print(cfg.EnvFile())
|
|
return nil
|
|
}
|