// Package plan computes what provisioning would do, without doing any of it. // // A plan is read by a person before it is applied, so it is written to be read: // it says what it will attempt, what it cannot know in advance, and what it // refuses. The refusals are the important part -- a reconciler that quietly // works around a situation it does not understand is worse than one that stops. package plan import ( "errors" "fmt" "strings" "github.com/tegwick/fluid-telegram/internal/avatar" "github.com/tegwick/fluid-telegram/internal/spec" "github.com/tegwick/fluid-telegram/internal/state" ) type Kind int const ( // Create makes something that does not exist. Create Kind = iota // Update changes something that does, in a way that is safe to repeat. Update // Attempt is a Create whose outcome cannot be known in advance -- claiming a // username, for instance. The plan promises the attempt, not the result. Attempt // Warn is something the operator should know that the tool will not act on. Warn // Defer is an action held back until a precondition the design expects to be // met later. It is not an error: the rest of the plan still applies. The // public channel waiting on a checked rendering is the case this exists for, // and it is normal on every first run. Defer // Block is a refusal caused by the world disagreeing with the state file. // A plan containing one applies nothing, because the tool no longer knows // what it is looking at. Block ) func (k Kind) String() string { switch k { case Create: return "create" case Update: return "update" case Attempt: return "attempt" case Warn: return "warn" case Defer: return "defer" case Block: return "BLOCK" } return "?" } type Action struct { Kind Kind Target string Detail string // Why is present on Warn and Block, where the reason matters more than the // action. An operator who is told only "blocked" will look for a way around. Why string } type Plan struct { Campaign string SpecDigest string SpecPath string Actions []Action // Avatar is the validated image, when the spec declares one and it loaded. // apply uses it rather than re-reading the file, so the thing that was // checked is the thing that is sent. Avatar *avatar.Image } func (p *Plan) add(k Kind, target, detail string) { p.Actions = append(p.Actions, Action{k, target, detail, ""}) } func (p *Plan) addWhy(k Kind, target, detail, why string) { p.Actions = append(p.Actions, Action{k, target, detail, why}) } // Blocked reports whether the plan may not be applied. func (p *Plan) Blocked() bool { for _, a := range p.Actions { if a.Kind == Block { return true } } return false } // Empty reports whether applying would change nothing. Warnings and deferrals do // not count as changes: a converged presence with a standing warning, or with the // public channel still waiting on its first checked rendering, is still converged. func (p *Plan) Empty() bool { for _, a := range p.Actions { if a.Kind != Warn && a.Kind != Defer { return false } } return true } // Live is what the provisioner can observe about the presence right now. It is // an interface so that plan computation stays pure and testable: the MTProto // client is one implementation, a fake is another. type Live interface { // BotExists reports whether a bot with this username is ours and reachable. BotExists(username string) (bool, error) // ChannelAdminRights returns the rights our bot actually holds on a chat. ChannelAdminRights(chatID int64) ([]string, error) // ChannelUsername returns the username a chat currently carries. ChannelUsername(chatID int64) (string, error) } // Compute diffs the spec against the resolved state and live observation. // specPath locates the spec so that relative asset paths resolve against the // repository that declares them. func Compute(specPath string, sp *spec.Presence, digest string, rs *state.Resolved, live Live) (*Plan, error) { p := &Plan{Campaign: sp.Campaign, SpecDigest: digest, SpecPath: specPath} if rs.Campaign != "" && rs.Campaign != sp.Campaign { p.addWhy(Block, "campaign", fmt.Sprintf("state is for %q, spec is for %q", rs.Campaign, sp.Campaign), "The campaign slug names the state file and the OpenBao subtree. Changing it "+ "does not rename a presence, it points at a different one, so the tool will "+ "not guess which was meant.") return p, nil } if err := planBot(p, sp, rs, live); err != nil { return nil, err } if err := planChannels(p, sp, rs, live); err != nil { return nil, err } // Nothing here deletes. Removing a channel from the spec destroys its // subscribers and its post history irreversibly, and no spec is trusted // with that. for name := range rs.Channels { if _, ok := sp.Channels[name]; !ok { p.addWhy(Warn, "channel."+name, "present in state, absent from spec", "Not deleted. Deleting a channel destroys its subscribers and history "+ "irreversibly; remove it by hand if that is genuinely what you want.") } } return p, nil } func planBot(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error { if rs.Bot.ID == 0 { p.add(Attempt, "bot", fmt.Sprintf("register %q via BotFather, username from %d candidate(s): %s", sp.Bot.Name, len(sp.Bot.UsernamePreference), strings.Join(sp.Bot.UsernamePreference, ", "))) p.add(Create, "bot.profile", "set name, about text and description") planAvatar(p, sp, rs) return nil } ok, err := live.BotExists(rs.Bot.Username) if err != nil { return fmt.Errorf("check bot: %w", err) } if !ok { p.addWhy(Block, "bot", fmt.Sprintf("@%s is in the resolved state but is not reachable", rs.Bot.Username), "Either the bot was deleted or the operator session no longer has access to it. "+ "Both mean the state file is describing something that is not there, and "+ "re-creating the bot would silently orphan the token in OpenBao.") return nil } // Profile fields are safe to reassert: BotFather takes them idempotently and // the tool does not know what a person may have changed by hand. p.add(Update, "bot.profile", "reassert name, about text and description from the spec") planAvatar(p, sp, rs) return nil } // planAvatar decides whether the picture needs sending. It is content // addressed: replacing the file is what triggers an update, because a timestamp // says when something was touched and a digest says whether it differs. func planAvatar(p *Plan, sp *spec.Presence, rs *state.Resolved) { if sp.Bot.Avatar == "" { return } img, err := avatar.Load(p.SpecPath, sp.Bot.Avatar) if errors.Is(err, avatar.ErrMissing) { p.addWhy(Defer, "bot.avatar", sp.Bot.Avatar, "The spec declares an avatar but the file is not there. Everything else "+ "applies; add the file and run again to set the picture.") return } if err != nil { p.addWhy(Block, "bot.avatar", sp.Bot.Avatar, err.Error()+ ". The picture is checked before the conversation starts, so that a "+ "rejected image cannot leave the bot registered without one.") return } p.Avatar = &img if rs.Bot.AvatarDigest == img.Digest { return // unchanged } kind := Create detail := fmt.Sprintf("%s (%dx%d, %d KiB)", sp.Bot.Avatar, img.Width, img.Height, img.Bytes/1024) if rs.Bot.AvatarDigest != "" { kind = Update detail = "replace picture with " + detail } if note := img.CropNote(); note != "" { p.addWhy(kind, "bot.avatar", detail, note) return } p.add(kind, "bot.avatar", detail) } func planChannels(p *Plan, sp *spec.Presence, rs *state.Resolved, live Live) error { // Test first, always. The ordering is the guarantee, not a convention. for _, name := range []string{spec.Test, spec.Live} { c, ok := sp.Channels[name] if !ok { continue } rc, provisioned := rs.Channels[name] if name == spec.Live && !rs.TestChannelVerified(spec.Test) { p.addWhy(Defer, "channel.public", "held until the test channel has a checked rendering", "Nothing reaches the public channel until a person has seen a rendering in "+ "the test channel. This is expected on a first run and does not stop the "+ "rest of the plan; publish once to the test channel, then plan again.") continue } if !provisioned { p.add(Create, "channel."+name, fmt.Sprintf("%s channel %q", c.Visibility, c.Title)) if c.Visibility == spec.Public { p.add(Attempt, "channel."+name+".username", "claim from: "+strings.Join(c.UsernamePreference, ", ")) } p.add(Create, "channel."+name+".admin", "add bot as administrator with post_messages only") continue } rights, err := live.ChannelAdminRights(rc.ChatID) if err != nil { return fmt.Errorf("check rights on %s: %w", name, err) } if !hasOnly(rights, spec.PostMessages) { p.addWhy(Block, "channel."+name+".admin", fmt.Sprintf("bot holds %v, expected [%s]", rights, spec.PostMessages), "Rights drifted. Widening is a permission this system is not allowed to "+ "exercise; losing post_messages means it cannot publish. Either way a "+ "person decides what happened before a tool changes it back.") } if c.Visibility == spec.Public { cur, err := live.ChannelUsername(rc.ChatID) if err != nil { return fmt.Errorf("check username on %s: %w", name, err) } if cur != rc.Username { p.addWhy(Block, "channel."+name+".username", fmt.Sprintf("is @%s, state says @%s", cur, rc.Username), "A channel's public username changing under us is not something to "+ "reconcile. It may have been taken over, or the state may be stale.") } } } return nil } func hasOnly(got []string, want string) bool { return len(got) == 1 && got[0] == want } // Render writes the plan the way an operator reads it: refusals first, because // they decide whether the rest matters. func (p *Plan) Render() string { var b strings.Builder fmt.Fprintf(&b, "presence plan for %q\n", p.Campaign) fmt.Fprintf(&b, "spec %s\n\n", p.SpecDigest) if p.Empty() && !p.Blocked() { b.WriteString(" no changes -- the declared presence matches what exists\n") } for _, k := range []Kind{Block, Warn, Defer, Attempt, Create, Update} { for _, a := range p.Actions { if a.Kind != k { continue } fmt.Fprintf(&b, " %-8s %-26s %s\n", a.Kind, a.Target, a.Detail) if a.Why != "" { for _, line := range wrap(a.Why, 72) { fmt.Fprintf(&b, " %s\n", line) } } } } if p.Blocked() { b.WriteString("\nnothing will be applied while a BLOCK stands: the presence does not\n" + "match the state file, and a person decides what happened before a tool acts\n") } return b.String() } func wrap(s string, width int) []string { var out []string line := "" for _, w := range strings.Fields(s) { if line != "" && len(line)+1+len(w) > width { out = append(out, line) line = "" } if line == "" { line = w } else { line += " " + w } } if line != "" { out = append(out, line) } return out }