Go, per the toolchain decision. cmd/provision with internal/spec, internal/state and internal/plan; plan computation is pure and takes live observation through an interface, so the refusal logic is testable without a Telegram account. It runs against the real campaign spec today. Separates two refusals the design had treated as one. A deferral is the design working -- the public channel waiting on a checked rendering, normal on every first run. A block is the world disagreeing with the state file: drifted rights, a taken-over username, a bot that is no longer reachable. Collapsed together, a first run could never apply anything, because it always defers the public channel. The rights clamp and the private/public username rule are enforced in Go and asserted against the same cases the JSON schema rejects, since mirroring the schema in code is a drift risk worth a test rather than a comment. 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
138 lines
4.1 KiB
Go
138 lines
4.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 (
|
|
"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 <path> [--check] show what would change; writes nothing
|
|
provision apply --spec <path> 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
|
|
}
|