oficina / memoria Public

Clone
git clone https://amadan.net/oficina/memoria

Plain git — no account needed to clone.

Download

Download this file

1package memoria
2
3import (
4 "fmt"
5 "net/url"
6 "sort"
7 "strings"
8
9 "amadan.net/oficina/suite"
10
11 "amadan.net/oficina/memoria/internal/memoria/sibling"
12)
13
14// SiblingConfig is the roster of apps of record memoria may ask about
15// passages, read from platform config.
16//
17// **The token is a separate variable from the address**, per app. Two
18// reasons, and neither is style. A roster is the sort of thing that gets
19// logged, printed by an operator, or pasted into a ticket while somebody
20// works out why a page is short; if the token travels in the same string
21// it goes with it. And rotation (§6.2's three phases) changes one
22// variable rather than rewriting a list, which is what makes a rotation
23// interruptible and resumable rather than an edit that is half-applied.
24type SiblingConfig struct {
25 // Team is the label the transport binds to, so a call cannot leave
26 // the team's own hosts (§6.4). Required as soon as one sibling is
27 // configured, and meaningless without one.
28 Team string
29 Apps []sibling.Sibling
30}
31
32// LoadSiblings reads the roster.
33//
34// MEMORIA_TEAM=acme
35// MEMORIA_SIBLINGS=Docs=https://acme.docs.oficina.works,Sheets=https://acme.sheets.oficina.works
36// MEMORIA_SIBLING_TOKEN_DOCS=...
37// MEMORIA_SIBLING_TOKEN_SHEETS=...
38//
39// **The host is `<team>.<app>`, team first** (§6.3). This comment had the
40// labels the other way round, and so did every fixture — which meant the
41// roster accepted an address the transport refuses on every single call.
42// See the host check below for what that costs.
43//
44// **No siblings is a valid configuration and not a warning.** Slice 0
45// holds only memoria-owned content (§4.2), which has no app of record to
46// ask, so an empty roster is the ordinary state of an instance that has
47// not adopted a source yet.
48//
49// **A half-configured one is not.** Every refusal below is a case where
50// the alternative is silence: a sibling with no token, or no team to bind
51// the transport to, cannot be asked — and a sibling that cannot be asked
52// withholds every passage it owns. The reader sees a page that is
53// permanently short with a notice blaming an app that is, in fact, fine.
54// So it is refused at boot, where it is a startup error somebody reads,
55// rather than at query time, where it is a mystery.
56func LoadSiblings(getenv func(string) string) (SiblingConfig, error) {
57 var c SiblingConfig
58 c.Team = strings.TrimSpace(getenv("MEMORIA_TEAM"))
59
60 raw := strings.TrimSpace(getenv("MEMORIA_SIBLINGS"))
61 if raw == "" {
62 // An instance with no siblings needs no team. Saying so here
63 // rather than requiring MEMORIA_TEAM unconditionally keeps slice
64 // 0's configuration exactly as small as slice 0 needs.
65 return c, nil
66 }
67
68 // Checked before the loop, not after it. The per-sibling host check
69 // below needs a team to compare against, so without this an instance
70 // missing MEMORIA_TEAM fails with the module's "no team configured"
71 // — accurate, and not the message that tells an operator which
72 // variable to set.
73 if c.Team == "" {
74 return c, fmt.Errorf("%w: MEMORIA_TEAM, which binds the sibling transport to one team's hosts (§6.4). Without it there is nothing for the host guard to compare against", ErrMissing)
75 }
76
77 seen := map[string]bool{}
78 for i, entry := range strings.Split(raw, ",") {
79 entry = strings.TrimSpace(entry)
80 if entry == "" {
81 continue
82 }
83 name, base, ok := strings.Cut(entry, "=")
84 name, base = strings.TrimSpace(name), strings.TrimSpace(base)
85 if !ok || name == "" || base == "" {
86 // **The entry is counted, not echoed.** main logs this error,
87 // and MEMORIA_SIBLINGS sits beside the token variables: one
88 // transposed assignment and a malformed "entry" is a bearer
89 // token in `carlos logs`, which is storage we cannot delete.
90 // The position is enough to find it in the variable.
91 return c, fmt.Errorf("%w: MEMORIA_SIBLINGS entry %d is not name=url", ErrMissing, i+1)
92 }
93 // **Keyed on the token variable, not on the name**, because that
94 // is the collision that matters: "Docs" and "docs" are different
95 // map keys and different Document.Source values, but they read
96 // the same MEMORIA_SIBLING_TOKEN_DOCS — so one app silently
97 // receives the other's credential. Keying on the name alone
98 // missed it, and a review found it.
99 if seen[tokenVar(name)] {
100 return c, fmt.Errorf("memoria: MEMORIA_SIBLINGS lists two apps that resolve to %s; the name is the key a document's source resolves through, and the token variable is derived from it", tokenVar(name))
101 }
102 seen[tokenVar(name)] = true
103
104 // https only, and said here rather than left to the transport.
105 // A consumer token on a cleartext hop is a credential handed to
106 // anyone on the path, and the roster is where the address is
107 // chosen.
108 if !strings.HasPrefix(base, "https://") {
109 return c, fmt.Errorf("memoria: sibling %q is not https", name)
110 }
111
112 // **The address must be one the transport will actually dial**,
113 // checked here with the module's own predicate rather than with a
114 // second implementation of it.
115 //
116 // This was missing, and the cost was the exact failure this
117 // function exists to prevent. `CheckSiblingHost` requires
118 // `<team>.<app>.oficina.works` — **team first** — and this file's
119 // own documented example had the labels reversed. An instance
120 // configured from it boots, logs "siblings configured", and then
121 // has every S1, S2 and S3 refused before the transport dials.
122 // What a reader sees is "Docs did not answer; N results withheld"
123 // on every query forever, naming an app that is working
124 // perfectly, and there is nothing in the logs but "sibling poll
125 // failed". A boot failure is the whole point of the paragraph
126 // above; without this check it was a boot failure that did not
127 // happen.
128 u, err := url.Parse(base)
129 if err != nil {
130 return c, fmt.Errorf("memoria: sibling %q has an unparseable address", name)
131 }
132 if err := suite.CheckSiblingHost(c.Team, u.Hostname()); err != nil {
133 return c, fmt.Errorf("memoria: sibling %q: %w", name, err)
134 }
135
136 token := strings.TrimSpace(getenv(tokenVar(name)))
137 if token == "" {
138 return c, fmt.Errorf("%w: %s for sibling %q. A sibling with no token cannot be asked, and one that cannot be asked withholds every passage it owns — silently short pages rather than a boot failure", ErrMissing, tokenVar(name), name)
139 }
140 c.Apps = append(c.Apps, sibling.Sibling{Name: name, BaseURL: strings.TrimRight(base, "/"), Token: token})
141 }
142
143 sort.Slice(c.Apps, func(i, j int) bool { return c.Apps[i].Name < c.Apps[j].Name })
144 return c, nil
145}
146
147// tokenVar is the per-app token variable. Upper-cased with non-alphanumerics
148// folded to underscores, so a display name like "Team Docs" has one
149// spelling here and cannot be configured two ways.
150func tokenVar(name string) string {
151 var b strings.Builder
152 b.WriteString("MEMORIA_SIBLING_TOKEN_")
153 for _, r := range strings.ToUpper(name) {
154 switch {
155 case r >= 'A' && r <= 'Z', r >= '0' && r <= '9':
156 b.WriteRune(r)
157 default:
158 b.WriteByte('_')
159 }
160 }
161 return b.String()
162}
163
164// Names lists the configured apps, for a log line that must never carry a
165// token. The tokens are in the same structs; this is the accessor that
166// exists so nobody reaches for %+v.
167func (c SiblingConfig) Names() []string {
168 out := make([]string, 0, len(c.Apps))
169 for _, s := range c.Apps {
170 out = append(out, s.Name)
171 }
172 return out
173}
174