carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package changelog
2
3import (
4 "encoding/json"
5 "reflect"
6 "strings"
7 "testing"
8)
9
10func TestBranchName(t *testing.T) {
11 cases := map[string]string{
12 "fix/canary-one-label\n\nSquashed from fix/canary-one-label into main.": "fix/canary-one-label",
13 "deploy-credential: a thing (#229)\n\nSquashed from design-deploy-credential into main.": "design-deploy-credential",
14 "Merge branch 'tenant-iso-mail' into main\n\nMail: members can't adopt": "tenant-iso-mail",
15 "Merge pull request #409 from carlosframework/edge-replica-placements\n\nprovision": "edge-replica-placements",
16 "Merge pull request #12 from someone/feat/nested-name": "feat/nested-name",
17 "console: a Database tile on the app Overview": "",
18 }
19 for msg, want := range cases {
20 if got := BranchName(msg); got != want {
21 t.Errorf("BranchName(%q) = %q, want %q", msg, got, want)
22 }
23 }
24}
25
26func TestDiscussionRefs(t *testing.T) {
27 msg := "console: refuse (#199 stopgap)\n\nSee discussion 207 and amadan #155; also #199 again.\nissue #412 and PR #409."
28 got := DiscussionRefs(msg)
29 want := []int{155, 199, 207, 409, 412}
30 if !reflect.DeepEqual(got, want) {
31 t.Fatalf("DiscussionRefs = %v, want %v", got, want)
32 }
33 if got := DiscussionRefs("colour #fff and a #1abc anchor"); len(got) != 0 {
34 t.Fatalf("DiscussionRefs(hex) = %v, want none", got)
35 }
36}
37
38const bulletNotes = "carlos v0.24.0 (platform eef3e121, 2026-10-06)\n\n" +
39 "- **Canaries have a one-label address, and can be created again.** A canary's host is now `<name>-<app>`. You need this release's CLI.\n" +
40 "- **One address, one app.** `carlos instances create` and `carlos canary` are refused when another app holds the host.\n" +
41 " It continues here.\n\n" +
42 "**For operators**\n\n" +
43 "- **Check every box's secrets key before rolling the boxes.** A box now takes an exec instance only if it can open the config.\n\n" +
44 "Verify a download against `SHA256SUMS.txt`; `SHA256SUMS.txt.sig` is the ed25519 signature (base64) over that file.\n"
45
46func TestParseNotesBullets(t *testing.T) {
47 n := ParseNotes(bulletNotes)
48 if n.Form != "bullets" {
49 t.Fatalf("Form = %q", n.Form)
50 }
51 if len(n.Sections) != 2 || n.Sections[0].Title != "" || n.Sections[1].Title != "For operators" {
52 t.Fatalf("sections = %+v", n.Sections)
53 }
54 b := n.Sections[0].Bullets
55 if len(b) != 2 || b[0].Lead != "Canaries have a one-label address, and can be created again." {
56 t.Fatalf("bullets = %+v", b)
57 }
58 if !strings.HasSuffix(b[1].MD, "the host.\nIt continues here.") {
59 t.Fatalf("continuation lost: %q", b[1].MD)
60 }
61 if len(n.Sections[1].Bullets) != 1 {
62 t.Fatalf("operator bullets = %+v", n.Sections[1].Bullets)
63 }
64 for _, s := range n.Sections {
65 for _, bl := range s.Bullets {
66 if strings.Contains(bl.MD, "Verify a download") {
67 t.Fatalf("boilerplate kept: %q", bl.MD)
68 }
69 }
70 }
71}
72
73func TestParseNotesOperatorHeadingVariants(t *testing.T) {
74 for _, h := range []string{"**Before you roll**", "**Before you roll a console to this release**", "**For operators**"} {
75 body := "carlos v0.20.0 (platform 0d2fdd44, 2026-09-23)\n\n- **A.** a.\n\n" + h + "\n\n- **B.** b.\n"
76 n := ParseNotes(body)
77 if len(n.Sections) != 2 || n.Sections[1].Title != strings.Trim(h, "*") {
78 t.Errorf("%s: sections = %+v", h, n.Sections)
79 }
80 }
81}
82
83func TestParseNotesMarkdown(t *testing.T) {
84 body := "## carlos deploy — seconds to live\n\nOne command.\n\n```\n## not a heading\n```\n\n### Three things\n\ntext\n\n## Install\n\n- Homebrew: brew install\n\nSHA256SUMS.txt is signed.\n\n## After install\n\nkept\n\n🤖 Generated with [Claude Code](https://claude.com/claude-code)\n"
85 n := ParseNotes(body)
86 if n.Form != "markdown" {
87 t.Fatalf("Form = %q", n.Form)
88 }
89 for _, want := range []string{"#### carlos deploy", "##### Three things", "\n## not a heading\n", "#### After install"} {
90 if !strings.Contains(n.MD, want) {
91 t.Errorf("markdown lacks %q:\n%s", want, n.MD)
92 }
93 }
94 for _, gone := range []string{"Install", "Homebrew", "SHA256SUMS", "Generated with"} {
95 if strings.Contains(n.MD, gone) {
96 t.Errorf("markdown kept %q:\n%s", gone, n.MD)
97 }
98 }
99}
100
101func TestParseNotesOneParagraph(t *testing.T) {
102 n := ParseNotes("First tagged release of `carlos`. Built from carlosframework/platform @ v0.1.0.\n")
103 if n.Form != "markdown" || !strings.HasPrefix(n.MD, "First tagged release") {
104 t.Fatalf("notes = %+v", n)
105 }
106}
107
108func TestParseNotesEmailLikeTextSurvives(t *testing.T) {
109 body := "carlos v0.23.0 (platform x, 2026-10-05)\n\n- **Operators.** live for bill@ on Tito's console.\n"
110 n := ParseNotes(body)
111 if len(n.Sections) != 1 || !strings.Contains(n.Sections[0].Bullets[0].MD, "bill@") {
112 t.Fatalf("notes = %+v", n)
113 }
114}
115
116func TestCLIDocLinks(t *testing.T) {
117 headings := map[string]string{
118 "carlos db": "carlos-db",
119 "carlos store": "carlos-store",
120 "carlos store scan": "carlos-store-scan",
121 "carlos services": "carlos-services",
122 }
123 md := "`carlos db pull --app <a>` and `carlos store scan` and `carlos services create --role deploy` and `carlos nope` and `carlos db query`"
124 got := CLIDocLinks(md, headings)
125 want := []string{"/docs/cli#carlos-db", "/docs/cli#carlos-store-scan", "/docs/cli#carlos-services"}
126 if !reflect.DeepEqual(got, want) {
127 t.Fatalf("CLIDocLinks = %v, want %v", got, want)
128 }
129}
130
131// flatGolden is ParseNotes(bulletNotes) marshalled by the parser before
132// nested items existed. Notes without a nested list must keep it byte for
133// byte, so a reader that predates items sees no change.
134const flatGolden = "{\"form\":\"bullets\",\"sections\":[{\"title\":\"\",\"bullets\":[{\"md\":\"**Canaries have a one-label address, and can be created again.** A canary's host is now `\\u003cname\\u003e-\\u003capp\\u003e`. You need this release's CLI.\"},{\"md\":\"**One address, one app.** `carlos instances create` and `carlos canary` are refused when another app holds the host.\\nIt continues here.\"}]},{\"title\":\"For operators\",\"bullets\":[{\"md\":\"**Check every box's secrets key before rolling the boxes.** A box now takes an exec instance only if it can open the config.\"}]}]}"
135
136func TestParseNotesFlatUnchanged(t *testing.T) {
137 got, err := json.Marshal(ParseNotes(bulletNotes))
138 if err != nil {
139 t.Fatal(err)
140 }
141 if string(got) != flatGolden {
142 t.Fatalf("flat notes changed:\n got %s\nwant %s", got, flatGolden)
143 }
144}
145
146// nestedNotes is the v0.26.0 operator bullets that carry a sub-list, as
147// published, with a flat bullet between and after them.
148const nestedNotes = "carlos v0.26.0 (platform 09c5a4a5, 2026-10-13)\n" +
149 "\n" +
150 "**For operators**\n" +
151 "\n" +
152 "- **Before any box rolls, run the instance key checks.** This release writes a `CARLOS_INSTANCE_KEY` into each instance's `<host>.env` the first time it starts, and starts a replica only with its home's key.\n" +
153 "\n" +
154 " - On every box, `find <data> -maxdepth 1 -name '*.env' -type l` must print nothing. A `<host>.env` that is a symlink now fails that instance's start. Stop the instance, replace the link with the real file, and treat the token in it as leaked.\n" +
155 " - On every box with a replica (`sqlite3 <data>/registry.db \"select host from routes where replica=1\"`), compare each replica's key with its home's. A key is the last `CARLOS_INSTANCE_KEY` line of `<host>.env`, or of `<host>.config.env` when `<host>.env` has none, in the data directory for a flat instance or the instance's own directory for an isolated one. Where the two match, add `<replica-host>=<sha256 of the key>` to `/etc/carlos/replica-home-keys` on the replica's box before it rolls. Write the whole list to a temporary file in `/etc/carlos`, make it root:carlos 0640, and rename it into place. Where they differ, the replica is refused after the roll. Tell its owner first.\n" +
156 " - A replica with no key keeps starting where it has a control socket, but its legacy sidecar and any worker row of its own stop at the roll. List them on each box with a replica, and tell their owners.\n" +
157 " - Check the agent can read the list: `sudo -u <agent user> test -r /etc/carlos/replica-home-keys && echo ok` must print `ok`.\n" +
158 " - After the roll, `journalctl -u carlos-edge --since '<roll time>' | grep 'agent: instance keys:'` must show `0 failed`.\n" +
159 "\n" +
160 "- **Root ignores AWS profile, endpoint and trust-store settings.** A box's root processes now ignore `AWS_PROFILE`, `AWS_DEFAULT_PROFILE`, `AWS_CA_BUNDLE`, the `AWS_ENDPOINT_URL` variables, `SSL_CERT_FILE`, `SSL_CERT_DIR` and shared AWS config files, and log an alert when one is set. Before rolling, check that no box's host.env depends on them. On a box outside AWS, `/etc/carlos/credentials` must hold only static keys under `[default]`. The agent rewrites it as root:carlos 0640 on every start.\n" +
161 "\n" +
162 "- **Every dormant switch stays off.** Leave each of these unset, and run none of the commands named, until its own rollout is approved.\n" +
163 "\n" +
164 " - `CARLOS_SWAP` on every box. Two builds at once still has open items, and only the exact word `overlap` arms it.\n" +
165 " - `CARLOS_MOVES_PHASE1` on the console and every box. Don't publish a moves protocol floor (`control/moves-protocol.json`) either.\n" +
166 " - `CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS` and `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS` on the console and every box, and `CARLOS_CONSOLE_ID` on the console. Don't run `carlos system guard-arm` or `carlos system journal-migrate`.\n" +
167 " - `CARLOS_REGION_HA` on every box.\n" +
168 " - `CONSOLE_APP_REGIONS` and `CARLOS_TEMPORARY_APPS` on the console.\n" +
169 " - `CARLOS_REQUIRE_POINTER_DIGEST` on every box. Don't run `carlos channels digests on` yet.\n" +
170 "\n" +
171 "- **Host claims: every roster reader first.** Binaries older than this release reject a signed roster that names a home region. So before anyone publishes the home designation, every reader of `control/host-actors.json` must run this release: every console, every box's agent and root services, the update and rollback tools, and any offline or recovery binary. The designation is write-once, and this roll doesn't publish it.\n" +
172 "\n" +
173 "Verify a download against `SHA256SUMS.txt`. `SHA256SUMS.txt.sig` is the ed25519 signature (base64) over that file, and `carlos update` checks it against its embedded key.\n"
174
175var instanceKeyItems = []string{
176 "On every box, `find <data> -maxdepth 1 -name '*.env' -type l` must print nothing. A `<host>.env` that is a symlink now fails that instance's start. Stop the instance, replace the link with the real file, and treat the token in it as leaked.",
177 "On every box with a replica (`sqlite3 <data>/registry.db \"select host from routes where replica=1\"`), compare each replica's key with its home's. A key is the last `CARLOS_INSTANCE_KEY` line of `<host>.env`, or of `<host>.config.env` when `<host>.env` has none, in the data directory for a flat instance or the instance's own directory for an isolated one. Where the two match, add `<replica-host>=<sha256 of the key>` to `/etc/carlos/replica-home-keys` on the replica's box before it rolls. Write the whole list to a temporary file in `/etc/carlos`, make it root:carlos 0640, and rename it into place. Where they differ, the replica is refused after the roll. Tell its owner first.",
178 "A replica with no key keeps starting where it has a control socket, but its legacy sidecar and any worker row of its own stop at the roll. List them on each box with a replica, and tell their owners.",
179 "Check the agent can read the list: `sudo -u <agent user> test -r /etc/carlos/replica-home-keys && echo ok` must print `ok`.",
180 "After the roll, `journalctl -u carlos-edge --since '<roll time>' | grep 'agent: instance keys:'` must show `0 failed`.",
181}
182var dormantItems = []string{
183 "`CARLOS_SWAP` on every box. Two builds at once still has open items, and only the exact word `overlap` arms it.",
184 "`CARLOS_MOVES_PHASE1` on the console and every box. Don't publish a moves protocol floor (`control/moves-protocol.json`) either.",
185 "`CARLOS_HOST_CLAIM`, `CARLOS_HOST_CLAIM_ORIGINS` and `CARLOS_HOST_CLAIM_RETIRED_LOCATIONS` on the console and every box, and `CARLOS_CONSOLE_ID` on the console. Don't run `carlos system guard-arm` or `carlos system journal-migrate`.",
186 "`CARLOS_REGION_HA` on every box.",
187 "`CONSOLE_APP_REGIONS` and `CARLOS_TEMPORARY_APPS` on the console.",
188 "`CARLOS_REQUIRE_POINTER_DIGEST` on every box. Don't run `carlos channels digests on` yet.",
189}
190
191func TestParseNotesNestedItems(t *testing.T) {
192 n := ParseNotes(nestedNotes)
193 if n.Form != "bullets" || len(n.Sections) != 1 || n.Sections[0].Title != "For operators" {
194 t.Fatalf("notes = %+v", n)
195 }
196 b := n.Sections[0].Bullets
197 if len(b) != 4 {
198 t.Fatalf("got %d bullets, want 4: %+v", len(b), b)
199 }
200 want := []struct {
201 lead string
202 items []string
203 }{
204 {"Before any box rolls, run the instance key checks.", instanceKeyItems},
205 {"Root ignores AWS profile, endpoint and trust-store settings.", nil},
206 {"Every dormant switch stays off.", dormantItems},
207 {"Host claims: every roster reader first.", nil},
208 }
209 for i, w := range want {
210 if b[i].Lead != w.lead {
211 t.Errorf("bullet %d lead = %q, want %q", i, b[i].Lead, w.lead)
212 }
213 if !reflect.DeepEqual(b[i].Items, w.items) {
214 t.Errorf("bullet %d items = %q, want %q", i, b[i].Items, w.items)
215 }
216 if strings.Contains(b[i].MD, "\n") {
217 t.Errorf("bullet %d md took a nested line: %q", i, b[i].MD)
218 }
219 }
220 if len(instanceKeyItems) != 5 || len(dormantItems) != 6 {
221 t.Fatalf("fixture lists: %d and %d items", len(instanceKeyItems), len(dormantItems))
222 }
223 out, err := json.Marshal(b[1])
224 if err != nil {
225 t.Fatal(err)
226 }
227 if strings.Contains(string(out), `"items"`) {
228 t.Errorf("a flat bullet marshals items: %s", out)
229 }
230}
231
232func TestParseNotesNestedItemContinuation(t *testing.T) {
233 body := "carlos v0.26.0 (platform 09c5a4a5, 2026-10-13)\n\n" +
234 "- **A.** a.\n - one\n more of one\n - two\n\n" +
235 "after a gap, outside any bullet\n\n- **B.** b.\n"
236 b := ParseNotes(body).Sections[0].Bullets
237 if len(b) != 2 || b[0].MD != "**A.** a." || !reflect.DeepEqual(b[0].Items, []string{"one\nmore of one", "two"}) {
238 t.Fatalf("bullets = %+v", b)
239 }
240 if b[1].MD != "**B.** b." || b[1].Items != nil {
241 t.Fatalf("second bullet = %+v", b[1])
242 }
243}
244