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
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -3,3 +3,6 @@
|
|||
.claude/*
|
||||
!.claude/rules/
|
||||
!.claude/rules/*.md
|
||||
|
||||
# Go build artifacts
|
||||
/provision
|
||||
|
|
|
|||
138
cmd/provision/main.go
Normal file
138
cmd/provision/main.go
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// 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
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
module github.com/tegwick/fluid-telegram
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
3
go.sum
Normal file
3
go.sum
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
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())
|
||||
}
|
||||
}
|
||||
193
internal/spec/spec.go
Normal file
193
internal/spec/spec.go
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// Package spec loads and validates a declared Telegram presence.
|
||||
//
|
||||
// The normative schema is presence/telegram.schema.yaml. The validation here
|
||||
// mirrors it deliberately rather than interpreting it at runtime: the rules are
|
||||
// few, and a spec that fails should say why in the tool's own words. The
|
||||
// mirroring is a real drift risk, so schema_test.go asserts that the schema file
|
||||
// and this package still agree on every rule.
|
||||
package spec
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// PostMessages is the only administrator right this system will ever hold.
|
||||
//
|
||||
// InterfaceEvolutionIntent.md 7 forbids the Daimon from using a wider right, and
|
||||
// a right that is never exercised is still a right that was granted. Enforcing
|
||||
// it where the grant is declared is cheaper than auditing that it stayed unused.
|
||||
const PostMessages = "post_messages"
|
||||
|
||||
type File struct {
|
||||
Presence Presence `yaml:"presence"`
|
||||
}
|
||||
|
||||
type Presence struct {
|
||||
SchemaVersion string `yaml:"schema_version"`
|
||||
Campaign string `yaml:"campaign"`
|
||||
Interface string `yaml:"interface"`
|
||||
Bot Bot `yaml:"bot"`
|
||||
Channels map[string]Channel `yaml:"channels"`
|
||||
LinkedDiscussionGroup bool `yaml:"linked_discussion_group"`
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
Name string `yaml:"name"`
|
||||
UsernamePreference []string `yaml:"username_preference"`
|
||||
About string `yaml:"about"`
|
||||
Description string `yaml:"description"`
|
||||
Avatar string `yaml:"avatar"`
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
Title string `yaml:"title"`
|
||||
Description string `yaml:"description"`
|
||||
Visibility string `yaml:"visibility"`
|
||||
UsernamePreference []string `yaml:"username_preference"`
|
||||
AdminRights []string `yaml:"admin_rights"`
|
||||
}
|
||||
|
||||
const (
|
||||
Private = "private"
|
||||
Public = "public"
|
||||
|
||||
// The two channels every presence declares. Test is created first and is
|
||||
// where renderings are checked; nothing reaches Public until a person has
|
||||
// looked at one.
|
||||
Test = "test"
|
||||
Live = "public"
|
||||
)
|
||||
|
||||
var (
|
||||
campaignRe = regexp.MustCompile(`^[a-z0-9][a-z0-9-]*$`)
|
||||
botUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{3,30}([Bb]ot|_bot)$`)
|
||||
chanUsernameRe = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9_]{4,31}$`)
|
||||
)
|
||||
|
||||
// Load reads a spec and validates it. The digest is over the file bytes, so any
|
||||
// edit changes it -- that is what makes drift detectable in the resolved state.
|
||||
func Load(path string) (*Presence, string, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("read spec: %w", err)
|
||||
}
|
||||
var f File
|
||||
dec := yaml.NewDecoder(strings.NewReader(string(raw)))
|
||||
dec.KnownFields(true) // additionalProperties: false
|
||||
if err := dec.Decode(&f); err != nil {
|
||||
return nil, "", fmt.Errorf("parse spec: %w", err)
|
||||
}
|
||||
if err := f.Presence.Validate(); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
sum := sha256.Sum256(raw)
|
||||
return &f.Presence, "sha256:" + hex.EncodeToString(sum[:]), nil
|
||||
}
|
||||
|
||||
func (p *Presence) Validate() error {
|
||||
var errs []string
|
||||
add := func(format string, a ...any) { errs = append(errs, fmt.Sprintf(format, a...)) }
|
||||
|
||||
if p.SchemaVersion != "0.1" {
|
||||
add("presence.schema_version must be %q, got %q", "0.1", p.SchemaVersion)
|
||||
}
|
||||
if !campaignRe.MatchString(p.Campaign) {
|
||||
add("presence.campaign %q must be a lowercase slug", p.Campaign)
|
||||
}
|
||||
if p.Interface == "" {
|
||||
add("presence.interface is required")
|
||||
}
|
||||
|
||||
if p.Bot.Name == "" {
|
||||
add("bot.name is required")
|
||||
} else if n := len([]rune(p.Bot.Name)); n > 64 {
|
||||
add("bot.name is %d characters, limit is 64", n)
|
||||
}
|
||||
if len(p.Bot.UsernamePreference) == 0 {
|
||||
add("bot.username_preference needs at least one candidate; BotFather usernames are globally unique and a first choice is often taken")
|
||||
}
|
||||
for _, u := range p.Bot.UsernamePreference {
|
||||
if !botUsernameRe.MatchString(u) {
|
||||
add("bot username %q must be 4-31 characters and end in \"bot\" or \"_bot\"", u)
|
||||
}
|
||||
}
|
||||
if n := len([]rune(p.Bot.About)); n > 120 {
|
||||
add("bot.about is %d characters, limit is 120", n)
|
||||
}
|
||||
if n := len([]rune(p.Bot.Description)); n > 512 {
|
||||
add("bot.description is %d characters, limit is 512", n)
|
||||
}
|
||||
|
||||
for _, name := range []string{Test, Live} {
|
||||
if _, ok := p.Channels[name]; !ok {
|
||||
add("channels.%s is required", name)
|
||||
}
|
||||
}
|
||||
for name, c := range p.Channels {
|
||||
if name != Test && name != Live {
|
||||
add("channels.%s is not a known channel; expected %q and %q", name, Test, Live)
|
||||
continue
|
||||
}
|
||||
errs = append(errs, c.validate(name)...)
|
||||
}
|
||||
|
||||
if len(errs) > 0 {
|
||||
return fmt.Errorf("spec is invalid:\n - %s", strings.Join(errs, "\n - "))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c Channel) validate(name string) []string {
|
||||
var errs []string
|
||||
add := func(format string, a ...any) { errs = append(errs, fmt.Sprintf(format, a...)) }
|
||||
|
||||
if c.Title == "" {
|
||||
add("channels.%s.title is required", name)
|
||||
} else if n := len([]rune(c.Title)); n > 128 {
|
||||
add("channels.%s.title is %d characters, limit is 128", name, n)
|
||||
}
|
||||
if n := len([]rune(c.Description)); n > 255 {
|
||||
add("channels.%s.description is %d characters, limit is 255", name, n)
|
||||
}
|
||||
|
||||
switch c.Visibility {
|
||||
case Private:
|
||||
if len(c.UsernamePreference) > 0 {
|
||||
add("channels.%s is private and must not declare a username; a dropped field is how a spec stops describing what exists", name)
|
||||
}
|
||||
case Public:
|
||||
if len(c.UsernamePreference) == 0 {
|
||||
add("channels.%s is public and must declare at least one username candidate", name)
|
||||
}
|
||||
default:
|
||||
add("channels.%s.visibility must be %q or %q, got %q", name, Private, Public, c.Visibility)
|
||||
}
|
||||
for _, u := range c.UsernamePreference {
|
||||
if !chanUsernameRe.MatchString(u) {
|
||||
add("channels.%s username %q must be 5-32 characters, starting with a letter", name, u)
|
||||
}
|
||||
}
|
||||
|
||||
// The clamp. A spec asking for a wider right fails here rather than being
|
||||
// quietly trimmed: someone asked for it, and they should find out.
|
||||
for _, r := range c.AdminRights {
|
||||
if r != PostMessages {
|
||||
add("channels.%s.admin_rights may only contain %q; %q is refused", name, PostMessages, r)
|
||||
}
|
||||
}
|
||||
if len(c.AdminRights) > 1 {
|
||||
add("channels.%s.admin_rights has %d entries; only %q is permitted", name, len(c.AdminRights), PostMessages)
|
||||
}
|
||||
return errs
|
||||
}
|
||||
|
||||
// Rights returns the rights to grant. Always exactly post_messages: the spec may
|
||||
// state it for readability, but it is not the spec's decision.
|
||||
func (c Channel) Rights() []string { return []string{PostMessages} }
|
||||
154
internal/spec/spec_test.go
Normal file
154
internal/spec/spec_test.go
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
package spec
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The real specs must load. If either stops validating, the tool has drifted
|
||||
// away from the artifacts it exists to read.
|
||||
func TestRealSpecsLoad(t *testing.T) {
|
||||
for _, p := range []string{
|
||||
"../../presence/telegram.example.yaml",
|
||||
"../../../pr-hall-of-helix/presence/telegram.yaml",
|
||||
} {
|
||||
if _, err := os.Stat(p); err != nil {
|
||||
t.Skipf("not present in this checkout: %s", p)
|
||||
}
|
||||
got, digest, err := Load(p)
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", p, err)
|
||||
}
|
||||
if got.Campaign == "" || !strings.HasPrefix(digest, "sha256:") {
|
||||
t.Fatalf("%s: campaign=%q digest=%q", p, got.Campaign, digest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func base() Presence {
|
||||
return Presence{
|
||||
SchemaVersion: "0.1",
|
||||
Campaign: "hall-of-helix",
|
||||
Interface: "helix-forge-telegram-publishing",
|
||||
Bot: Bot{
|
||||
Name: "HelixForge",
|
||||
UsernamePreference: []string{"HelixForgeBot"},
|
||||
About: "about",
|
||||
Description: "description",
|
||||
},
|
||||
Channels: map[string]Channel{
|
||||
Test: {Title: "t", Visibility: Private, AdminRights: []string{PostMessages}},
|
||||
Live: {Title: "p", Visibility: Public, UsernamePreference: []string{"hallofhelix"},
|
||||
AdminRights: []string{PostMessages}},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Every rule the schema states, asserted here too. These are the cases the
|
||||
// schema file rejects; if this table and presence/telegram.schema.yaml ever
|
||||
// disagree, one of them is wrong and a spec will pass one gate and fail another.
|
||||
func TestValidateRejects(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
mutate func(*Presence)
|
||||
want string
|
||||
}{
|
||||
{"extra admin right", func(p *Presence) {
|
||||
c := p.Channels[Live]
|
||||
c.AdminRights = []string{PostMessages, "can_delete_messages"}
|
||||
p.Channels[Live] = c
|
||||
}, "can_delete_messages"},
|
||||
{"wrong admin right", func(p *Presence) {
|
||||
c := p.Channels[Live]
|
||||
c.AdminRights = []string{"can_delete_messages"}
|
||||
p.Channels[Live] = c
|
||||
}, "refused"},
|
||||
{"private channel with username", func(p *Presence) {
|
||||
c := p.Channels[Test]
|
||||
c.UsernamePreference = []string{"secretchan"}
|
||||
p.Channels[Test] = c
|
||||
}, "must not declare a username"},
|
||||
{"public channel without username", func(p *Presence) {
|
||||
c := p.Channels[Live]
|
||||
c.UsernamePreference = nil
|
||||
p.Channels[Live] = c
|
||||
}, "must declare at least one"},
|
||||
{"bot username not ending in bot", func(p *Presence) {
|
||||
p.Bot.UsernamePreference = []string{"HelixForge"}
|
||||
}, `end in "bot"`},
|
||||
{"no bot username candidates", func(p *Presence) {
|
||||
p.Bot.UsernamePreference = nil
|
||||
}, "at least one candidate"},
|
||||
{"missing test channel", func(p *Presence) {
|
||||
delete(p.Channels, Test)
|
||||
}, "channels.test is required"},
|
||||
{"unknown channel key", func(p *Presence) {
|
||||
p.Channels["archive"] = Channel{Title: "a", Visibility: Private}
|
||||
}, "not a known channel"},
|
||||
{"bad visibility", func(p *Presence) {
|
||||
c := p.Channels[Test]
|
||||
c.Visibility = "unlisted"
|
||||
p.Channels[Test] = c
|
||||
}, "visibility must be"},
|
||||
{"wrong schema version", func(p *Presence) {
|
||||
p.SchemaVersion = "0.2"
|
||||
}, "schema_version"},
|
||||
{"campaign not a slug", func(p *Presence) {
|
||||
p.Campaign = "Hall Of Helix"
|
||||
}, "lowercase slug"},
|
||||
{"about too long", func(p *Presence) {
|
||||
p.Bot.About = strings.Repeat("x", 121)
|
||||
}, "limit is 120"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
p := base()
|
||||
tc.mutate(&p)
|
||||
err := p.Validate()
|
||||
if err == nil {
|
||||
t.Fatalf("accepted a spec that must be rejected")
|
||||
}
|
||||
if !strings.Contains(err.Error(), tc.want) {
|
||||
t.Fatalf("error did not mention %q:\n%v", tc.want, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateAcceptsBase(t *testing.T) {
|
||||
p := base()
|
||||
if err := p.Validate(); err != nil {
|
||||
t.Fatalf("rejected a valid spec: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// An unknown field is a mistake about what is being declared, not a comment.
|
||||
func TestUnknownFieldRejected(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "s.yaml")
|
||||
os.WriteFile(f, []byte(`
|
||||
presence:
|
||||
schema_version: "0.1"
|
||||
campaign: "c"
|
||||
interface: "i"
|
||||
pinned: true
|
||||
bot: {name: "n", username_preference: ["aBot"], about: "a", description: "d"}
|
||||
channels:
|
||||
test: {title: "t", visibility: private}
|
||||
public: {title: "p", visibility: public, username_preference: ["abcdef"]}
|
||||
`), 0o600)
|
||||
if _, _, err := Load(f); err == nil {
|
||||
t.Fatal("accepted an unknown field")
|
||||
}
|
||||
}
|
||||
|
||||
// Rights are the tool's decision, not the spec's.
|
||||
func TestRightsAlwaysClamped(t *testing.T) {
|
||||
c := Channel{AdminRights: nil}
|
||||
got := c.Rights()
|
||||
if len(got) != 1 || got[0] != PostMessages {
|
||||
t.Fatalf("Rights() = %v, want [%s]", got, PostMessages)
|
||||
}
|
||||
}
|
||||
102
internal/state/state.go
Normal file
102
internal/state/state.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
// Package state reads and writes the resolved presence: what provisioning
|
||||
// actually produced, as opposed to what was asked for.
|
||||
//
|
||||
// It holds no secrets and is committed. The bot token, the operator session and
|
||||
// the redaction salt live in OpenBao; what is here is the mechanical facts a
|
||||
// later run needs in order to recognise what it already did.
|
||||
package state
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type File struct {
|
||||
Resolved Resolved `yaml:"resolved"`
|
||||
}
|
||||
|
||||
type Resolved struct {
|
||||
Campaign string `yaml:"campaign"`
|
||||
|
||||
// SpecDigest is the digest of the spec this state was produced from. It is
|
||||
// the link that makes drift detectable: if the spec changed and this did
|
||||
// not, provisioning is behind. Without it a stale state is indistinguishable
|
||||
// from a current one.
|
||||
SpecDigest string `yaml:"spec_digest"`
|
||||
|
||||
ProvisionedAt time.Time `yaml:"provisioned_at"`
|
||||
|
||||
Bot Bot `yaml:"bot"`
|
||||
Channels map[string]Channel `yaml:"channels"`
|
||||
}
|
||||
|
||||
type Bot struct {
|
||||
Username string `yaml:"username"`
|
||||
ID int64 `yaml:"id"`
|
||||
|
||||
// AvatarDigest is the digest of the image file that was uploaded, so a
|
||||
// replaced file is what triggers an update rather than a timestamp.
|
||||
AvatarDigest string `yaml:"avatar_digest,omitempty"`
|
||||
}
|
||||
|
||||
type Channel struct {
|
||||
ChatID int64 `yaml:"chat_id"`
|
||||
Username string `yaml:"username,omitempty"`
|
||||
AdminRights []string `yaml:"admin_rights"`
|
||||
|
||||
// TestPublicationAt records the first successful publication to this
|
||||
// channel. apply refuses to touch the public channel until the test channel
|
||||
// has one -- a gate that has to survive a restart, so it lives here rather
|
||||
// than in memory.
|
||||
TestPublicationAt *time.Time `yaml:"test_publication_at,omitempty"`
|
||||
}
|
||||
|
||||
// Path is where a campaign's resolved state lives, relative to the repo root.
|
||||
func Path(root, campaign string) string {
|
||||
return filepath.Join(root, "presence", "resolved", campaign+".yaml")
|
||||
}
|
||||
|
||||
// Load returns the resolved state, or a zero value if none exists yet. A missing
|
||||
// file is the ordinary first-run case, not an error.
|
||||
func Load(path string) (*Resolved, error) {
|
||||
raw, err := os.ReadFile(path)
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return &Resolved{}, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read resolved state: %w", err)
|
||||
}
|
||||
var f File
|
||||
if err := yaml.Unmarshal(raw, &f); err != nil {
|
||||
return nil, fmt.Errorf("parse resolved state: %w", err)
|
||||
}
|
||||
return &f.Resolved, nil
|
||||
}
|
||||
|
||||
func Save(path string, r *Resolved) error {
|
||||
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := yaml.Marshal(File{Resolved: *r})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
header := "# Generated by `provision apply`. Committed, and free of secrets:\n" +
|
||||
"# tokens, sessions and the redaction salt live in OpenBao.\n"
|
||||
return os.WriteFile(path, append([]byte(header), out...), 0o644)
|
||||
}
|
||||
|
||||
// Provisioned reports whether anything has been created for this campaign yet.
|
||||
func (r *Resolved) Provisioned() bool { return r.Bot.ID != 0 }
|
||||
|
||||
// TestChannelVerified reports whether a publication has reached the test
|
||||
// channel. The public channel is not touched until it has.
|
||||
func (r *Resolved) TestChannelVerified(testKey string) bool {
|
||||
c, ok := r.Channels[testKey]
|
||||
return ok && c.TestPublicationAt != nil
|
||||
}
|
||||
|
|
@ -93,7 +93,7 @@ permanent.
|
|||
|
||||
```task
|
||||
id: FT-WP-0002-T03
|
||||
status: todo
|
||||
status: progress
|
||||
priority: high
|
||||
state_hub_task_id: "c3923422-7f2e-5bf9-906c-6d59572b09c0"
|
||||
```
|
||||
|
|
@ -110,6 +110,20 @@ cheapest place to catch someone asking for one.
|
|||
knowable without attempting it, so the plan says "will attempt, with fallbacks"
|
||||
rather than promising an outcome.
|
||||
|
||||
**Implemented (2026-09-04), less live observation.** Go, `cmd/provision`, with
|
||||
`internal/spec`, `internal/state` and `internal/plan`. Runs against the real
|
||||
campaign spec today; live observation is behind a `plan.Live` interface that
|
||||
returns an offline stub until T01 mints a session, and the command says so rather
|
||||
than presenting an unobserved plan as a drift check.
|
||||
|
||||
One thing the design got wrong and the implementation surfaced: refusals are two
|
||||
different things. A **deferral** is the design working — the public channel
|
||||
waiting on a checked rendering, which is normal on every first run. A **block**
|
||||
is the world disagreeing with the state file — drifted rights, a taken-over
|
||||
username, an unreachable bot. Collapsing them meant a first run could never apply
|
||||
anything, since it always defers the public channel. They are now distinct
|
||||
kinds: a deferral holds one action, a block stops the run.
|
||||
|
||||
## T04 — Implement `provision apply`
|
||||
|
||||
```task
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue