carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package hostactors_test
2
3import (
4 "bytes"
5 "context"
6 "crypto/ed25519"
7 "errors"
8 "fmt"
9 "io"
10 "net/http"
11 "strings"
12 "sync"
13 "testing"
14 "time"
15
16 awss3 "github.com/aws/aws-sdk-go-v2/service/s3"
17
18 "github.com/carlosframework/platform/internal/hostactors"
19 "github.com/carlosframework/platform/internal/hostops"
20 "github.com/carlosframework/platform/internal/release"
21)
22
23// sdkRequest is one HTTP request the AWS SDK sent: its method, its path,
24// its If-Match condition, and how long its context had left when it was
25// sent (zero: no deadline).
26type sdkRequest struct {
27 method, path, ifMatch string
28 left time.Duration
29}
30
31// recordingHTTP stands in for the network under the SDK: a bucket that
32// honours S3's conditional headers. It records each request's context
33// deadline. A GET answers the object and its ETag, or NoSuchKey; a PUT
34// with If-Match succeeds only on the current ETag, one with
35// If-None-Match: * only when the key is absent, and gets a new ETag.
36type recordingHTTP struct {
37 mu sync.Mutex
38 seen []sdkRequest
39 objects map[string]bucketObject
40 next int
41}
42
43type bucketObject struct {
44 body []byte
45 etag string
46}
47
48func (h *recordingHTTP) Do(r *http.Request) (*http.Response, error) {
49 var left time.Duration
50 if dl, ok := r.Context().Deadline(); ok {
51 left = time.Until(dl)
52 }
53 var body []byte
54 if r.Body != nil {
55 body, _ = io.ReadAll(r.Body)
56 }
57 h.mu.Lock()
58 defer h.mu.Unlock()
59 h.seen = append(h.seen, sdkRequest{r.Method, r.URL.Path, r.Header.Get("If-Match"), left})
60 resp := &http.Response{Header: http.Header{}, Request: r, Proto: "HTTP/1.1", ProtoMajor: 1, ProtoMinor: 1, Body: io.NopCloser(strings.NewReader(""))}
61 reply := func(code int, xml string) (*http.Response, error) {
62 resp.StatusCode = code
63 resp.Header.Set("Content-Type", "application/xml")
64 resp.Body = io.NopCloser(strings.NewReader(`<?xml version="1.0" encoding="UTF-8"?><Error><Code>` + xml + `</Code><Message>fake</Message></Error>`))
65 return resp, nil
66 }
67 key := r.URL.Path
68 cur, exists := h.objects[key]
69 switch r.Method {
70 case http.MethodPut:
71 if m := r.Header.Get("If-Match"); m != "" && (!exists || strings.Trim(m, `"`) != strings.Trim(cur.etag, `"`)) {
72 return reply(http.StatusPreconditionFailed, "PreconditionFailed")
73 }
74 if r.Header.Get("If-None-Match") == "*" && exists {
75 return reply(http.StatusPreconditionFailed, "PreconditionFailed")
76 }
77 h.next++
78 obj := bucketObject{body, fmt.Sprintf(`"e%d"`, h.next)}
79 h.objects[key] = obj
80 resp.StatusCode = http.StatusOK
81 resp.Header.Set("ETag", obj.etag)
82 return resp, nil
83 case http.MethodGet:
84 if !exists {
85 return reply(http.StatusNotFound, "NoSuchKey")
86 }
87 resp.StatusCode = http.StatusOK
88 resp.Header.Set("ETag", cur.etag)
89 resp.Header.Set("Content-Length", fmt.Sprint(len(cur.body)))
90 resp.Body = io.NopCloser(bytes.NewReader(cur.body))
91 resp.ContentLength = int64(len(cur.body))
92 return resp, nil
93 }
94 return reply(http.StatusNotFound, "NoSuchKey")
95}
96
97func (h *recordingHTTP) take() []sdkRequest {
98 h.mu.Lock()
99 defer h.mu.Unlock()
100 out := h.seen
101 h.seen = nil
102 return out
103}
104
105// productionStores builds the console's stores as claim_runtime.go does:
106// the release S3 store (its own construction, s3compat and all, with only
107// the network replaced), the conditional record store over it, and the
108// observed mutation boundary with the production mutation check over
109// that: the store the enrolment pass's Directory carries.
110func productionStores(t *testing.T) (raw *release.S3Store, data release.Store, h *recordingHTTP, root release.Signer) {
111 t.Helper()
112 t.Setenv("AWS_ACCESS_KEY_ID", "k")
113 t.Setenv("AWS_SECRET_ACCESS_KEY", "s")
114 t.Setenv("AWS_SESSION_TOKEN", "")
115 t.Setenv("AWS_REGION", "eu-west-1")
116 t.Setenv("AWS_EC2_METADATA_DISABLED", "true")
117 t.Setenv("AWS_CONFIG_FILE", "/nonexistent")
118 t.Setenv("AWS_SHARED_CREDENTIALS_FILE", "/nonexistent")
119 t.Setenv("CARLOS_S3_ENDPOINT", "http://127.0.0.1:9")
120 raw, err := release.NewS3Store(context.Background(), "deployment")
121 if err != nil {
122 t.Fatal(err)
123 }
124 h = &recordingHTTP{objects: map[string]bucketObject{}}
125 raw.Client = awss3.New(raw.Client.Options(), func(o *awss3.Options) { o.HTTPClient = h; o.RetryMaxAttempts = 1 })
126 pub, priv, err := ed25519.GenerateKey(nil)
127 if err != nil {
128 t.Fatal(err)
129 }
130 control, err := hostops.NewControlWriter(raw, pub, time.Now)
131 if err != nil {
132 t.Fatal(err)
133 }
134 check, err := hostops.NewProductionMutationCheck(raw, control)
135 if err != nil {
136 t.Fatal(err)
137 }
138 records, err := hostops.NewConditionalRecordStore(raw, pub)
139 if err != nil {
140 t.Fatal(err)
141 }
142 data, err = hostops.NewObservedMutationBoundary(true, records, check, nil)
143 if err != nil {
144 t.Fatal(err)
145 }
146 return raw, data, h, release.RootSigner(priv)
147}
148
149// V2 and W1 (platform-1e): the per-call context of a bound write reaches
150// the AWS SDK's HTTP request through the console's production stores, so
151// the 15-second limit is enforced where the request is sent. The roster
152// write is a successful conditional PUT through the whole stack: the
153// bound's deadline store, the observed mutation boundary (its pre-read,
154// the production mutation check, which admits a root-signed roster, and
155// its forwarding), the conditional record store's forwarding and the S3
156// store. Every request it sends, the PUT included, carries a deadline of
157// 15 seconds from its call, or the write deadline when that comes first.
158// A wrapper anywhere on the path that drops the context, or a bound that
159// does not give each call its own, fails here.
160func TestTheBoundReachesTheSDKRequest(t *testing.T) {
161 _, data, h, root := productionStores(t)
162 for name, c := range map[string]struct {
163 readAge time.Duration
164 low, high time.Duration
165 }{
166 "the per-call limit": {0, 14 * time.Second, 15 * time.Second},
167 "the earlier write deadline": {50 * time.Second, 9 * time.Second, 10 * time.Second},
168 } {
169 t.Run(name, func(t *testing.T) {
170 check := func(what string, got []sdkRequest, methods ...string) []sdkRequest {
171 t.Helper()
172 var sent []string
173 for _, r := range got {
174 sent = append(sent, r.method)
175 if r.left <= c.low || r.left > c.high {
176 t.Errorf("%s: %s %s had %s left, want (%s, %s]", what, r.method, r.path, r.left, c.low, c.high)
177 }
178 }
179 if strings.Join(sent, " ") != strings.Join(methods, " ") {
180 t.Fatalf("%s: the SDK sent %v, want %v", what, sent, methods)
181 }
182 return got
183 }
184 // The bucket holds a roster the pass has read.
185 h.mu.Lock()
186 h.objects = map[string]bucketObject{}
187 h.mu.Unlock()
188 before, err := release.SignRootEnvelope(root, map[string]int{"revision": 1})
189 if err != nil {
190 t.Fatal(err)
191 }
192 d := hostactors.Directory{Store: data, Bucket: "deployment"}
193 etag, err := data.Put(context.Background(), hostactors.Key, before, "")
194 if err != nil {
195 t.Fatal(err)
196 }
197 h.take()
198 bd, bctx, cancel := hostactors.BindForTest(context.Background(), d, time.Now().Add(-c.readAge), time.Now)
199 defer cancel()
200 if _, got, err := bd.Store.Get(bctx, hostactors.Key); err != nil || got != etag {
201 t.Fatalf("roster read: %q %v", got, err)
202 }
203 check("the roster read", h.take(), http.MethodGet)
204 // The enrolling write: conditional on that ETag, and admitted.
205 next, err := release.SignRootEnvelope(root, map[string]int{"revision": 2})
206 if err != nil {
207 t.Fatal(err)
208 }
209 after, err := bd.Store.Put(bctx, hostactors.Key, next, etag)
210 if err != nil || after == etag {
211 t.Fatalf("the conditional roster write: %q %v", after, err)
212 }
213 sent := check("the roster write", h.take(), http.MethodGet, http.MethodPut)
214 if strings.Trim(sent[1].ifMatch, `"`) != strings.Trim(etag, `"`) {
215 t.Fatalf("the roster PUT was sent with If-Match %q, want the ETag read, %q", sent[1].ifMatch, etag)
216 }
217 // And it stays conditional: the old ETag now loses.
218 if _, err := bd.Store.Put(bctx, hostactors.Key, next, etag); !errors.Is(err, release.ErrCASConflict) {
219 t.Fatalf("a stale ETag was not refused: %v", err)
220 }
221 h.take()
222 })
223 }
224}
225