carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package instancectl
2
3import (
4 "context"
5 "encoding/json"
6 "net/http"
7 "net/http/httptest"
8 "strings"
9 "sync"
10 "testing"
11 "time"
12)
13
14// set changes one answer under the fake's lock, for a test whose unit
15// moves on while a handler polls.
16func (f *fakeCtl) set(prefix, out string) {
17 f.mu.Lock()
18 f.answers[prefix] = out
19 f.mu.Unlock()
20}
21
22const cmdShowAll = "show -p InvocationID,ActiveState,SubState,Result,ExecMainStatus,ExecMainCode"
23
24func fastCmd(s *Server) {
25 s.cmdPollEvery = 5 * time.Millisecond
26 s.cmdWaitBound = 300 * time.Millisecond
27 s.cmdStartBound = 300 * time.Millisecond
28}
29
30func TestCmdStartsWithoutBlockingAndAnswersTheNewInvocation(t *testing.T) {
31 ctl := &fakeCtl{answers: map[string]string{
32 "show -p ActiveState --value": "inactive\n",
33 "show -p InvocationID --value": "old\n",
34 }}
35 ctl.onStart = func() { ctl.set("show -p InvocationID --value", "new\n") }
36 _, c := newServer(t, ctl, fastCmd)
37 resp := post(t, c, "/cmd?host=h.example.com&memory_max=536870912")
38 if resp.StatusCode != 200 {
39 t.Fatalf("status %d", resp.StatusCode)
40 }
41 var out StartResponse
42 if err := json.NewDecoder(resp.Body).Decode(&out); err != nil || out.InvocationID != "new" {
43 t.Fatalf("response %+v, %v", out, err)
44 }
45 calls := ctl.joined()
46 for _, want := range []string{"set-property --runtime carlos-instances-h.example.com.slice", "start --no-block carlos-instance-cmd@h.example.com.service"} {
47 if !strings.Contains(calls, want) {
48 t.Errorf("missing %q in\n%s", want, calls)
49 }
50 }
51 if strings.Contains(calls, "stop ") {
52 t.Errorf("a started command was stopped:\n%s", calls)
53 }
54}
55
56func TestCmdRefusesAUnitStillLoaded(t *testing.T) {
57 for _, state := range []string{"active", "activating", "deactivating"} {
58 ctl := &fakeCtl{answers: map[string]string{"show -p ActiveState --value": state + "\n"}}
59 _, c := newServer(t, ctl, fastCmd)
60 resp := post(t, c, "/cmd?host=h.example.com&memory_max=536870912")
61 if resp.StatusCode != http.StatusConflict {
62 t.Errorf("%s: status %d, want 409", state, resp.StatusCode)
63 }
64 if strings.Contains(ctl.joined(), "start ") {
65 t.Errorf("%s: a loaded unit was started again:\n%s", state, ctl.joined())
66 }
67 if strings.Contains(ctl.joined(), "stop ") {
68 t.Errorf("%s: a loaded unit was stopped:\n%s", state, ctl.joined())
69 }
70 }
71}
72
73func TestCmdConcurrentStartsForOneHostAreOneStartAndOneRefusal(t *testing.T) {
74 ctl := &fakeCtl{answers: map[string]string{
75 "show -p ActiveState --value": "inactive\n",
76 "show -p InvocationID --value": "old\n",
77 }}
78 // The start loads the unit and gives it a new invocation, as systemd does.
79 ctl.onStart = func() {
80 ctl.set("show -p ActiveState --value", "active\n")
81 ctl.set("show -p InvocationID --value", "new\n")
82 }
83 _, c := newServer(t, ctl, fastCmd)
84 codes := make([]int, 2)
85 var wg sync.WaitGroup
86 for i := range codes {
87 wg.Add(1)
88 go func() {
89 defer wg.Done()
90 codes[i] = post(t, c, "/cmd?host=h.example.com&memory_max=536870912").StatusCode
91 }()
92 }
93 wg.Wait()
94 ok, conflict := 0, 0
95 for _, code := range codes {
96 switch code {
97 case 200:
98 ok++
99 case http.StatusConflict:
100 conflict++
101 }
102 }
103 if ok != 1 || conflict != 1 {
104 t.Fatalf("statuses %v, want one 200 and one 409", codes)
105 }
106 if strings.Contains(ctl.joined(), "stop ") {
107 t.Errorf("the started unit was stopped:\n%s", ctl.joined())
108 }
109}
110
111func TestCmdResetsAFailedUnitBeforeTheStart(t *testing.T) {
112 ctl := &fakeCtl{answers: map[string]string{
113 "show -p ActiveState --value": "failed\n",
114 "show -p InvocationID --value": "old\n",
115 }}
116 ctl.onStart = func() { ctl.set("show -p InvocationID --value", "new\n") }
117 _, c := newServer(t, ctl, fastCmd)
118 if resp := post(t, c, "/cmd?host=h.example.com&memory_max=536870912"); resp.StatusCode != 200 {
119 t.Fatalf("status %d", resp.StatusCode)
120 }
121 calls := ctl.joined()
122 if r, s := strings.Index(calls, "reset-failed carlos-instance-cmd@"), strings.Index(calls, "start --no-block"); r < 0 || s < 0 || r > s {
123 t.Errorf("reset-failed must come before the start:\n%s", calls)
124 }
125}
126
127func TestCmdStopsAUnitThatNeverReportsAnInvocation(t *testing.T) {
128 ctl := &fakeCtl{answers: map[string]string{
129 "show -p ActiveState --value": "inactive\n",
130 "show -p InvocationID --value": "old\n",
131 }}
132 _, c := newServer(t, ctl, fastCmd)
133 if resp := post(t, c, "/cmd?host=h.example.com&memory_max=536870912"); resp.StatusCode != http.StatusBadGateway {
134 t.Fatalf("status %d, want 502", resp.StatusCode)
135 }
136 if !strings.Contains(ctl.joined(), "stop carlos-instance-cmd@h.example.com.service") {
137 t.Errorf("the unit was not stopped:\n%s", ctl.joined())
138 }
139}
140
141func getStatus(t *testing.T, c *http.Client, path string) CmdStatus {
142 t.Helper()
143 resp, err := c.Get("http://unix" + path)
144 if err != nil {
145 t.Fatal(err)
146 }
147 defer resp.Body.Close()
148 if resp.StatusCode != 200 {
149 t.Fatalf("GET %s: status %d", path, resp.StatusCode)
150 }
151 var st CmdStatus
152 if err := json.NewDecoder(resp.Body).Decode(&st); err != nil {
153 t.Fatal(err)
154 }
155 return st
156}
157
158func TestCmdStatusLongPollsUntilDone(t *testing.T) {
159 ctl := &fakeCtl{answers: map[string]string{
160 cmdShowAll: "InvocationID=inv1\nActiveState=activating\nSubState=start\nResult=success\nExecMainStatus=0\nExecMainCode=0\n",
161 }}
162 _, c := newServer(t, ctl, fastCmd)
163 go func() {
164 time.Sleep(50 * time.Millisecond)
165 ctl.set(cmdShowAll, "InvocationID=inv1\nActiveState=active\nSubState=exited\nResult=success\nExecMainStatus=3\nExecMainCode=1\n")
166 }()
167 st := getStatus(t, c, "/cmd/h.example.com?invocation=inv1")
168 if st.State != CmdDone || st.Status != "3" || st.Code != "1" || st.InvocationID != "inv1" {
169 t.Fatalf("status = %+v", st)
170 }
171}
172
173func TestCmdStatusAnswersRunningAtTheBound(t *testing.T) {
174 ctl := &fakeCtl{answers: map[string]string{
175 cmdShowAll: "InvocationID=inv1\nActiveState=activating\nSubState=start\nResult=success\nExecMainStatus=0\nExecMainCode=0\n",
176 }}
177 _, c := newServer(t, ctl, fastCmd)
178 start := time.Now()
179 if st := getStatus(t, c, "/cmd/h.example.com?invocation=inv1"); st.State != CmdRunning {
180 t.Fatalf("status = %+v", st)
181 }
182 if took := time.Since(start); took < 250*time.Millisecond {
183 t.Errorf("answered running after %s, before the bound", took)
184 }
185}
186
187func TestCmdStatusDoneCoversFailedAndInactive(t *testing.T) {
188 for _, show := range []string{
189 "InvocationID=inv1\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=1\nExecMainCode=1\n",
190 "InvocationID=inv1\nActiveState=inactive\nSubState=dead\nResult=success\nExecMainStatus=0\nExecMainCode=1\n",
191 } {
192 ctl := &fakeCtl{answers: map[string]string{cmdShowAll: show}}
193 _, c := newServer(t, ctl, fastCmd)
194 if st := getStatus(t, c, "/cmd/h.example.com?invocation=inv1"); st.State != CmdDone {
195 t.Errorf("%q: state %s, want done", show, st.State)
196 }
197 }
198}
199
200func TestCmdStatusIsGoneForAnotherInvocation(t *testing.T) {
201 for _, show := range []string{
202 "InvocationID=inv2\nActiveState=active\nSubState=exited\nResult=success\nExecMainStatus=0\nExecMainCode=1\n",
203 "InvocationID=\nActiveState=inactive\nSubState=dead\nResult=success\nExecMainStatus=0\nExecMainCode=0\n",
204 } {
205 ctl := &fakeCtl{answers: map[string]string{cmdShowAll: show}}
206 _, c := newServer(t, ctl, fastCmd)
207 if st := getStatus(t, c, "/cmd/h.example.com?invocation=inv1"); st.State != CmdGone {
208 t.Errorf("%q: state %s, want gone", show, st.State)
209 }
210 }
211}
212
213func TestCmdRoutesRefuseWithClaimControlArmed(t *testing.T) {
214 called := false
215 s := &Server{ClaimStop: http.HandlerFunc(func(http.ResponseWriter, *http.Request) {}), Systemctl: func(context.Context, ...string) (string, error) { called = true; return "", nil }}
216 for _, req := range []*http.Request{
217 httptest.NewRequest(http.MethodPost, "/cmd?host=app.example&memory_max=536870912", nil),
218 httptest.NewRequest(http.MethodGet, "/cmd/app.example?invocation=x", nil),
219 } {
220 w := httptest.NewRecorder()
221 s.Handler().ServeHTTP(w, req)
222 if w.Code != http.StatusServiceUnavailable {
223 t.Errorf("%s %s: status %d", req.Method, req.URL, w.Code)
224 }
225 }
226 if called {
227 t.Fatal("a cmd route reached systemctl with claim control armed")
228 }
229}
230
231func TestClientCmdAndCmdStatus(t *testing.T) {
232 ctl := &fakeCtl{answers: map[string]string{
233 "show -p ActiveState --value": "inactive\n",
234 "show -p InvocationID --value": "old\n",
235 cmdShowAll: "InvocationID=new\nActiveState=failed\nSubState=failed\nResult=exit-code\nExecMainStatus=2\nExecMainCode=1\n",
236 }}
237 ctl.onStart = func() { ctl.set("show -p InvocationID --value", "new\n") }
238 _, cl := newClient(t, ctl, fastCmd)
239 inv, err := cl.Cmd(context.Background(), "h.example.com", 536870912)
240 if err != nil || inv != "new" {
241 t.Fatalf("Cmd = %q, %v", inv, err)
242 }
243 st, err := cl.CmdStatus(context.Background(), "h.example.com", inv)
244 if err != nil || st.State != CmdDone || st.Status != "2" {
245 t.Fatalf("CmdStatus = %+v, %v", st, err)
246 }
247}
248