Implement provision plan: spec, resolved state, and the diff
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
This commit is contained in:
parent
52723bd8ad
commit
10018a99b6
10 changed files with 1108 additions and 1 deletions
282
internal/plan/plan.go
Normal file
282
internal/plan/plan.go
Normal file
|
|
@ -0,0 +1,282 @@
|
|||
// 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 (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"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
|
||||
Actions []Action
|
||||
}
|
||||
|
||||
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.
|
||||
func Compute(sp *spec.Presence, digest string, rs *state.Resolved, live Live) (*Plan, error) {
|
||||
p := &Plan{Campaign: sp.Campaign, SpecDigest: digest}
|
||||
|
||||
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")
|
||||
if sp.Bot.Avatar != "" {
|
||||
p.add(Create, "bot.avatar", sp.Bot.Avatar)
|
||||
}
|
||||
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")
|
||||
return nil
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
213
internal/plan/plan_test.go
Normal file
213
internal/plan/plan_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package plan
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/tegwick/fluid-telegram/internal/spec"
|
||||
"github.com/tegwick/fluid-telegram/internal/state"
|
||||
)
|
||||
|
||||
type fake struct {
|
||||
botExists bool
|
||||
rights map[int64][]string
|
||||
usernames map[int64]string
|
||||
}
|
||||
|
||||
func (f fake) BotExists(string) (bool, error) { return f.botExists, nil }
|
||||
func (f fake) ChannelAdminRights(id int64) ([]string, error) {
|
||||
if r, ok := f.rights[id]; ok {
|
||||
return r, nil
|
||||
}
|
||||
return []string{spec.PostMessages}, nil
|
||||
}
|
||||
func (f fake) ChannelUsername(id int64) (string, error) { return f.usernames[id], nil }
|
||||
|
||||
func sp() *spec.Presence {
|
||||
return &spec.Presence{
|
||||
SchemaVersion: "0.1", Campaign: "hall-of-helix", Interface: "i",
|
||||
Bot: spec.Bot{Name: "HelixForge", UsernamePreference: []string{"HelixForgeBot"}},
|
||||
Channels: map[string]spec.Channel{
|
||||
spec.Test: {Title: "t", Visibility: spec.Private},
|
||||
spec.Live: {Title: "p", Visibility: spec.Public, UsernamePreference: []string{"hallofhelix"}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func find(p *Plan, kind Kind, target string) *Action {
|
||||
for i := range p.Actions {
|
||||
if p.Actions[i].Kind == kind && p.Actions[i].Target == target {
|
||||
return &p.Actions[i]
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// First run: everything is created, and the public channel is blocked because
|
||||
// no rendering has been checked yet.
|
||||
func TestFirstRunCreatesAndBlocksPublic(t *testing.T) {
|
||||
p, err := Compute(sp(), "sha256:x", &state.Resolved{}, fake{})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if find(p, Attempt, "bot") == nil {
|
||||
t.Error("expected a bot registration attempt")
|
||||
}
|
||||
if find(p, Create, "channel.test") == nil {
|
||||
t.Error("expected the test channel to be created")
|
||||
}
|
||||
if find(p, Defer, "channel.public") == nil {
|
||||
t.Error("public channel must be deferred until the test channel is verified")
|
||||
}
|
||||
// A first run is not an error state. The bot and the test channel must still
|
||||
// be creatable while the public channel waits.
|
||||
if p.Blocked() {
|
||||
t.Errorf("a first run should not block:\n%s", p.Render())
|
||||
}
|
||||
if p.Empty() {
|
||||
t.Error("a first run has work to do")
|
||||
}
|
||||
}
|
||||
|
||||
func provisioned(withTestPublication bool) *state.Resolved {
|
||||
rs := &state.Resolved{
|
||||
Campaign: "hall-of-helix",
|
||||
Bot: state.Bot{Username: "HelixForgeBot", ID: 42},
|
||||
Channels: map[string]state.Channel{
|
||||
spec.Test: {ChatID: -100, AdminRights: []string{spec.PostMessages}},
|
||||
spec.Live: {ChatID: -200, Username: "hallofhelix", AdminRights: []string{spec.PostMessages}},
|
||||
},
|
||||
}
|
||||
if withTestPublication {
|
||||
now := time.Now()
|
||||
c := rs.Channels[spec.Test]
|
||||
c.TestPublicationAt = &now
|
||||
rs.Channels[spec.Test] = c
|
||||
}
|
||||
return rs
|
||||
}
|
||||
|
||||
// Converged: a second run over an unchanged spec proposes nothing but the
|
||||
// idempotent profile reassertion.
|
||||
func TestConvergedRunIsQuiet(t *testing.T) {
|
||||
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}}
|
||||
p, err := Compute(sp(), "sha256:x", provisioned(true), f)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.Blocked() {
|
||||
t.Fatalf("converged plan should not block:\n%s", p.Render())
|
||||
}
|
||||
for _, a := range p.Actions {
|
||||
if a.Kind == Create || a.Kind == Attempt {
|
||||
t.Errorf("unexpected %s of %s on a converged presence", a.Kind, a.Target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Widened rights are a refusal, not a repair.
|
||||
func TestWidenedRightsBlock(t *testing.T) {
|
||||
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"},
|
||||
rights: map[int64][]string{-200: {spec.PostMessages, "can_delete_messages"}}}
|
||||
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
|
||||
a := find(p, Block, "channel.public.admin")
|
||||
if a == nil {
|
||||
t.Fatalf("widened rights must block:\n%s", p.Render())
|
||||
}
|
||||
if !strings.Contains(a.Why, "not allowed to exercise") {
|
||||
t.Errorf("block should explain why: %q", a.Why)
|
||||
}
|
||||
}
|
||||
|
||||
// A demoted bot blocks too -- losing the right is as much a drift as gaining one.
|
||||
func TestLostRightsBlock(t *testing.T) {
|
||||
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"},
|
||||
rights: map[int64][]string{-100: {}}}
|
||||
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
|
||||
if find(p, Block, "channel.test.admin") == nil {
|
||||
t.Fatalf("a demoted bot must block:\n%s", p.Render())
|
||||
}
|
||||
}
|
||||
|
||||
// A username that changed underneath us is not reconciled.
|
||||
func TestUsernameTakeoverBlocks(t *testing.T) {
|
||||
f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}}
|
||||
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
|
||||
if find(p, Block, "channel.public.username") == nil {
|
||||
t.Fatalf("a changed username must block:\n%s", p.Render())
|
||||
}
|
||||
}
|
||||
|
||||
// A missing bot blocks rather than being re-created, which would orphan the
|
||||
// token already in OpenBao.
|
||||
func TestMissingBotBlocks(t *testing.T) {
|
||||
f := fake{botExists: false}
|
||||
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
|
||||
if find(p, Block, "bot") == nil {
|
||||
t.Fatalf("an unreachable bot must block:\n%s", p.Render())
|
||||
}
|
||||
}
|
||||
|
||||
// Removing a channel from the spec warns; it never deletes.
|
||||
func TestRemovedChannelWarnsNeverDeletes(t *testing.T) {
|
||||
s := sp()
|
||||
delete(s.Channels, spec.Live)
|
||||
f := fake{botExists: true}
|
||||
p, _ := Compute(s, "sha256:x", provisioned(true), f)
|
||||
a := find(p, Warn, "channel.public")
|
||||
if a == nil {
|
||||
t.Fatalf("expected a warning:\n%s", p.Render())
|
||||
}
|
||||
for _, act := range p.Actions {
|
||||
if strings.Contains(strings.ToLower(act.Detail), "delete") && act.Kind != Warn {
|
||||
t.Errorf("plan proposed a deletion: %+v", act)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Pointing a state file at a different campaign is a mistake, not a rename.
|
||||
func TestCampaignMismatchBlocks(t *testing.T) {
|
||||
rs := provisioned(true)
|
||||
rs.Campaign = "some-other-campaign"
|
||||
p, _ := Compute(sp(), "sha256:x", rs, fake{botExists: true})
|
||||
if find(p, Block, "campaign") == nil {
|
||||
t.Fatalf("campaign mismatch must block:\n%s", p.Render())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderShowsRefusalsFirst(t *testing.T) {
|
||||
rs := provisioned(true)
|
||||
rs.Campaign = "some-other-campaign"
|
||||
p, _ := Compute(sp(), "sha256:x", rs, fake{botExists: true})
|
||||
out := p.Render()
|
||||
if !strings.Contains(out, "nothing will be applied") {
|
||||
t.Errorf("a blocked plan should say so:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "BLOCK") {
|
||||
t.Error("expected a BLOCK in the render")
|
||||
}
|
||||
}
|
||||
|
||||
// A deferral is not a block: the two must not collapse into each other, or a
|
||||
// first run can never apply anything.
|
||||
func TestDeferIsNotBlock(t *testing.T) {
|
||||
p, _ := Compute(sp(), "sha256:x", &state.Resolved{}, fake{})
|
||||
if p.Blocked() {
|
||||
t.Fatal("a deferral must not block the plan")
|
||||
}
|
||||
f := fake{botExists: true, usernames: map[int64]string{-200: "someoneelse"}}
|
||||
p2, _ := Compute(sp(), "sha256:x", provisioned(true), f)
|
||||
if !p2.Blocked() {
|
||||
t.Fatal("real drift must block")
|
||||
}
|
||||
}
|
||||
|
||||
// Once the test channel is verified, the public channel stops being deferred.
|
||||
func TestVerifiedTestChannelReleasesPublic(t *testing.T) {
|
||||
f := fake{botExists: true, usernames: map[int64]string{-200: "hallofhelix"}}
|
||||
p, _ := Compute(sp(), "sha256:x", provisioned(true), f)
|
||||
if find(p, Defer, "channel.public") != nil {
|
||||
t.Errorf("public should no longer be deferred:\n%s", p.Render())
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue