// 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 }