| 1 | package changelog |
| 2 | |
| 3 | import ( |
| 4 | "encoding/json" |
| 5 | "reflect" |
| 6 | "strings" |
| 7 | "testing" |
| 8 | ) |
| 9 | |
| 10 | func 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 | |
| 26 | func 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 | |
| 38 | const 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 | |
| 46 | func 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 | |
| 73 | func 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 | |
| 83 | func 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 | |
| 101 | func 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 | |
| 108 | func 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 | |
| 116 | func 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. |
| 134 | const 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 | |
| 136 | func 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. |
| 148 | const 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 | |
| 175 | var 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 | } |
| 182 | var 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 | |
| 191 | func 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 | |
| 232 | func 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 | |