carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package host
2
3import (
4 "context"
5 "strings"
6 "testing"
7 "time"
8
9 "github.com/carlosframework/platform/internal/release"
10)
11
12func TestRefusalWatchAlertsOncePerRecord(t *testing.T) {
13 bake := t.TempDir()
14 now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC)
15 if err := release.WriteRefusal(bake, release.Refusal{Host: "console.example.com", App: "console",
16 Version: "v2", RefusedAt: now.Add(-time.Minute), Reason: "exited: status=2"}); err != nil {
17 t.Fatal(err)
18 }
19 var posts []string
20 sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{})
21 w := &refusalWatch{}
22 got := w.tick(context.Background(), bake, sinks, now)
23 w.tick(context.Background(), bake, sinks, now.Add(time.Minute))
24 if len(posts) != 1 || !strings.Contains(posts[0], `"release_refused"`) || !strings.Contains(posts[0], "v2") {
25 t.Fatalf("posts = %v, want exactly one release_refused alert", posts)
26 }
27 if len(got) != 1 || got[0].Host != "console.example.com" {
28 t.Fatalf("status rows = %+v", got)
29 }
30}
31
32func TestRefusalWatchSkipsOldRecordsAtStartup(t *testing.T) {
33 bake := t.TempDir()
34 now := time.Date(2026, 10, 6, 12, 0, 0, 0, time.UTC)
35 if err := release.WriteRefusal(bake, release.Refusal{Host: "a.example.com", Version: "v2",
36 RefusedAt: now.Add(-2 * time.Hour)}); err != nil {
37 t.Fatal(err)
38 }
39 var posts int
40 sinks := newAlertSinks("https://hook.example", func(string, []byte) error { posts++; return nil }, "", nil, &alertCounters{})
41 (&refusalWatch{}).tick(context.Background(), bake, sinks, now)
42 if posts != 0 {
43 t.Fatalf("an agent restart re-alerted a refusal from two hours ago")
44 }
45}
46
47// Final review F6 and ledger Task 8: SNS and the webhook tell a recovered
48// rollback from one whose restored build did not come up either, and the
49// status row carries the failure for `status --live`.
50func TestRefusalWatchReportsTheRecoveryOutcome(t *testing.T) {
51 now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC)
52 for _, c := range []struct {
53 name string
54 rec release.Refusal
55 subject string
56 inMessage string
57 inBody string
58 rowErr string
59 }{
60 {"recovered", release.Refusal{RestoredVersion: "v1", Recovered: true},
61 "console.example.com: v2 did not start, rolled back to v1", "Retry with: carlos promote --retry v2",
62 `"recovered":true`, ""},
63 {"failed recovery", release.Refusal{RestoredVersion: "v1", RecoveryError: "start console.service: exit status 1"},
64 "console.example.com: v2 did not start and the rollback did not come up either",
65 "The rollback did not come up either: start console.service: exit status 1",
66 `"recovery_error":"start console.service: exit status 1"`, "start console.service: exit status 1"},
67 } {
68 t.Run(c.name, func(t *testing.T) {
69 bake := t.TempDir()
70 r := c.rec
71 r.Host, r.App, r.Version, r.RefusedAt, r.Reason = "console.example.com", "console", "v2", now.Add(-time.Minute), "exited: status=2"
72 if err := release.WriteRefusal(bake, r); err != nil {
73 t.Fatal(err)
74 }
75 var posts []string
76 pub := &fakeSNS{}
77 sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil },
78 testTopicARN, pub, &alertCounters{})
79 rows := (&refusalWatch{}).tick(context.Background(), bake, sinks, now)
80 if len(pub.in) != 1 || *pub.in[0].Subject != c.subject {
81 t.Fatalf("sns = %+v, want one publish with subject %q", pub.in, c.subject)
82 }
83 if !strings.Contains(*pub.in[0].Message, c.inMessage) {
84 t.Fatalf("sns message = %q, want %q in it", *pub.in[0].Message, c.inMessage)
85 }
86 if len(posts) != 1 || !strings.Contains(posts[0], c.inBody) {
87 t.Fatalf("webhook = %v, want %s in the body", posts, c.inBody)
88 }
89 if len(rows) != 1 || rows[0].RecoveryError != c.rowErr {
90 t.Fatalf("status rows = %+v, want recovery_error %q", rows, c.rowErr)
91 }
92 })
93 }
94}
95
96// D2's refusal is not a rollback: the subject says so and the message points
97// at a NEW version shipped with carlos ship --program-change (the refused
98// version's manifest is immutable), never --retry. The webhook keeps its envelope kind and
99// carries the record's as refusal_kind; the status row carries it too.
100func TestRefusalWatchReportsAWrongProgram(t *testing.T) {
101 bake := t.TempDir()
102 now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC)
103 rec := release.Refusal{Host: "console.example.com", App: "console", Version: "v2", RefusedAt: now.Add(-time.Minute),
104 Reason: "wrong program: example.com/carlos", Kind: release.RefusalWrongProgram, Recovered: true, RestoredVersion: "v1"}
105 if err := release.WriteRefusal(bake, rec); err != nil {
106 t.Fatal(err)
107 }
108 subject, msg := formatReleaseAlert(rec)
109 if subject != "console.example.com: v2 refused, wrong program (still on v1)" {
110 t.Fatalf("subject = %q", subject)
111 }
112 if !strings.Contains(msg, "Ship a new version with carlos ship --program-change and promote it, if the change is intended") || strings.Contains(msg, "--retry") {
113 t.Fatalf("message = %q", msg)
114 }
115 var posts []string
116 sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{})
117 rows := (&refusalWatch{}).tick(context.Background(), bake, sinks, now)
118 if len(posts) != 1 || !strings.Contains(posts[0], `"kind":"release_refused"`) || !strings.Contains(posts[0], `"refusal_kind":"wrong-program"`) {
119 t.Fatalf("posts = %v", posts)
120 }
121 if len(rows) != 1 || rows[0].Kind != release.RefusalWrongProgram {
122 t.Fatalf("rows = %+v", rows)
123 }
124}
125
126// D3's staged-bytes refusal is not a rollback either (plan 3 ruling 3): the
127// box never left its old build. The alert says the staged bytes do not match
128// dev's proof, names the build the box is still on, and asks for the exact
129// bytes dev proved; never --retry, never a rollback.
130func TestRefusalWatchReportsANotProven(t *testing.T) {
131 bake := t.TempDir()
132 now := time.Date(2026, 10, 6, 9, 31, 0, 0, time.UTC)
133 rec := release.Refusal{Host: "console.example.com", App: "console", Version: "v2", RefusedAt: now.Add(-time.Minute),
134 Reason: "not proven: staged sha256 aaaa, dev proved bbbb", Kind: release.RefusalNotProven, Recovered: true, RestoredVersion: "v1"}
135 if err := release.WriteRefusal(bake, rec); err != nil {
136 t.Fatal(err)
137 }
138 subject, msg := formatReleaseAlert(rec)
139 if subject != "console.example.com: v2 refused, staged bytes do not match dev's proof (still on v1)" {
140 t.Fatalf("subject = %q", subject)
141 }
142 for _, want := range []string{"staged sha256 aaaa, dev proved bbbb", "still on v1", "Roll the exact bytes dev proved"} {
143 if !strings.Contains(msg, want) {
144 t.Fatalf("message = %q, want %q in it", msg, want)
145 }
146 }
147 for _, never := range []string{"--retry", "rolled back", "rollback"} {
148 if strings.Contains(msg, never) || strings.Contains(subject, never) {
149 t.Fatalf("subject %q, message %q: %q has no place in a not-proven alert", subject, msg, never)
150 }
151 }
152 var posts []string
153 sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{})
154 rows := (&refusalWatch{}).tick(context.Background(), bake, sinks, now)
155 if len(posts) != 1 || !strings.Contains(posts[0], `"refusal_kind":"not-proven"`) {
156 t.Fatalf("posts = %v", posts)
157 }
158 if len(rows) != 1 || rows[0].Kind != release.RefusalNotProven || rows[0].RecoveryError != "" {
159 t.Fatalf("rows = %+v", rows)
160 }
161}
162
163// Final review finding 3: held refusals moved to their own namespace, and
164// status and alerts still surface them as they did when they shared
165// adoption's directory: one row each, beside an adoption refusal for the
166// same host, and one alert each, keyed apart from adoption's.
167func TestRefusalWatchReadsHeldRefusals(t *testing.T) {
168 bake := t.TempDir()
169 now := time.Date(2026, 10, 7, 9, 0, 0, 0, time.UTC)
170 host := "held.example.com"
171 if err := release.WriteRefusal(bake, release.Refusal{Host: host, App: "console", Version: "v1",
172 RefusedAt: now.Add(-time.Minute), Reason: "exited: status=2"}); err != nil {
173 t.Fatal(err)
174 }
175 if err := release.WriteRefusal(release.HeldRefusalBase(bake), release.Refusal{Host: host, App: "console", Version: "v2",
176 RefusedAt: now.Add(-2 * time.Minute), Reason: "not proven: staged sha256 bb", Kind: release.RefusalNotProven,
177 Recovered: true, MoveOp: "mv_abcdefghijklmnopqrstuvwxyz"}); err != nil {
178 t.Fatal(err)
179 }
180 var posts []string
181 sinks := newAlertSinks("https://hook.example", func(_ string, b []byte) error { posts = append(posts, string(b)); return nil }, "", nil, &alertCounters{})
182 w := &refusalWatch{}
183 rows := w.tick(context.Background(), bake, sinks, now)
184 w.tick(context.Background(), bake, sinks, now.Add(time.Minute))
185 var versions []string
186 for _, r := range rows {
187 versions = append(versions, r.Host+"@"+r.Version)
188 }
189 if strings.Join(versions, ",") != host+"@v1,"+host+"@v2" {
190 t.Fatalf("status rows %v, want the adoption refusal and the held one", versions)
191 }
192 if len(posts) != 2 {
193 t.Fatalf("%d alerts, want one per record: %v", len(posts), posts)
194 }
195}
196