Skip to content

Commit 4b7eb89

Browse files
authored
Merge pull request #29 from solinode/fix-ci-races
Fix CI: data races and flaky logs tail test
2 parents 930515d + 850a989 commit 4b7eb89

10 files changed

Lines changed: 104 additions & 20 deletions

File tree

‎cli/internal/awsapi/awstest/awstest.go‎

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import (
2222
"os/exec"
2323
"path/filepath"
2424
"strings"
25+
"sync"
2526
"testing"
2627
"time"
2728

@@ -44,8 +45,22 @@ type Harness struct {
4445
// Mux serves the native HomeCloud API (register routes with Router).
4546
Mux *http.ServeMux
4647
Router *httpx.Router
47-
// Audit records every audited call (action, resource).
48-
Audit []string
48+
// Audit records every audited call (action, resource); read it with AuditLog.
49+
Audit []string
50+
auditMu sync.Mutex
51+
// barrier orders a test's setup writes (fields set on services after New)
52+
// before the requests that read them; the race detector can't see that
53+
// ordering through the AWS CLI subprocess.
54+
barrier sync.Mutex
55+
}
56+
57+
func (h *Harness) sync() { h.barrier.Lock(); h.barrier.Unlock() }
58+
59+
// AuditLog returns a copy of the audited calls so far.
60+
func (h *Harness) AuditLog() []string {
61+
h.auditMu.Lock()
62+
defer h.auditMu.Unlock()
63+
return append([]string(nil), h.Audit...)
4964
}
5065

5166
// New starts an endpoint with IAM (root user) and STS.
@@ -65,12 +80,15 @@ func New(t *testing.T) *Harness {
6580
im.RegisterAWS()
6681
h := &Harness{Env: env, IAM: im, Secrets: sec, AccessKeyID: boot.AccessKeyID, SecretKey: boot.SecretKey, Mux: http.NewServeMux()}
6782
audit := func(p *httpx.Principal, action, resource string, r *http.Request, status int, took time.Duration) {
83+
h.auditMu.Lock()
6884
h.Audit = append(h.Audit, action+" "+resource)
85+
h.auditMu.Unlock()
6986
}
7087
h.Router = &httpx.Router{Mux: h.Mux, Auth: im, Account: env.AccountID, Audit: audit}
7188
im.Routes(h.Router)
7289
aws := &awsapi.Handler{Creds: im, Account: env.AccountID, Audit: audit}
7390
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
91+
h.sync()
7492
if awsapi.Match(r) && !strings.HasPrefix(r.Header.Get("Authorization"), "Bearer ") {
7593
aws.ServeHTTP(w, r)
7694
return
@@ -130,6 +148,7 @@ func (h *Harness) AWSErr(t *testing.T, args ...string) (string, error) {
130148
// AWSAs runs the AWS CLI with the given credentials.
131149
func (h *Harness) AWSAs(t *testing.T, akid, secret, token string, args ...string) (string, error) {
132150
t.Helper()
151+
h.sync()
133152
cmd := exec.Command(awsBin(t), append([]string{"--output", "json"}, args...)...)
134153
cmd.Env = h.Environ(akid, secret, token)
135154
var out bytes.Buffer
@@ -173,6 +192,7 @@ func (h *Harness) Python(t *testing.T, script string) string {
173192
if err := os.WriteFile(f, []byte(src), 0o600); err != nil {
174193
t.Fatal(err)
175194
}
195+
h.sync()
176196
cmd := exec.Command(python(t), f)
177197
cmd.Env = h.Environ(h.AccessKeyID, h.SecretKey, "")
178198
var out, errb bytes.Buffer
@@ -215,6 +235,7 @@ func (h *Harness) nativeAsRoot(t *testing.T, method, path string, body any) []by
215235
} else {
216236
rd = bytes.NewReader(nil)
217237
}
238+
h.sync()
218239
req, _ := http.NewRequest(method, h.URL+path, rd)
219240
req.Header.Set("Authorization", "Bearer "+h.AccessKeyID+":"+h.SecretKey)
220241
req.Header.Set("Content-Type", "application/json")

‎cli/internal/svc/cloudwatch/aws_test.go‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cloudwatch
33
import (
44
"bytes"
55
"encoding/json"
6+
"os"
67
"os/exec"
78
"strconv"
89
"strings"
@@ -171,12 +172,21 @@ func TestLogsTailFollow(t *testing.T) {
171172
defer cmd.Process.Kill()
172173
time.Sleep(2 * time.Second)
173174
h.AWS(t, "logs", "put-log-events", "--log-group-name", "follow", "--log-stream-name", "s", "--log-events", "timestamp="+msNow(0)+",message=second line")
174-
deadline := time.Now().Add(15 * time.Second)
175+
deadline := time.Now().Add(25 * time.Second)
175176
for time.Now().Before(deadline) && !strings.Contains(out.String(), "second line") {
176177
time.Sleep(250 * time.Millisecond)
177178
}
178-
_ = cmd.Process.Kill()
179-
_ = cmd.Wait()
179+
// Interrupt rather than kill: the CLI may block-buffer stdout when it isn't a
180+
// terminal (the bundled AWS CLI ignores PYTHONUNBUFFERED) and flushes on exit.
181+
_ = cmd.Process.Signal(os.Interrupt)
182+
done := make(chan struct{})
183+
go func() { _ = cmd.Wait(); close(done) }()
184+
select {
185+
case <-done:
186+
case <-time.After(10 * time.Second):
187+
_ = cmd.Process.Kill()
188+
<-done
189+
}
180190
if s := out.String(); !strings.Contains(s, "first line") || !strings.Contains(s, "second line") || strings.Count(s, "first line") != 1 {
181191
t.Fatalf("tail --follow output:\n%s", s)
182192
}

‎cli/internal/svc/dynamodb/aws_boto3_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -316,12 +316,12 @@ func TestIAMReadOnlyDeniedWrites(t *testing.T) {
316316
t.Fatalf("reader transact: %v %s", err, out)
317317
}
318318
found := false
319-
for _, a := range h.Audit {
319+
for _, a := range h.AuditLog() {
320320
if strings.HasPrefix(a, "dynamodb:PutItem arn:aws:dynamodb:") && strings.HasSuffix(a, ":table/acl") {
321321
found = true
322322
}
323323
}
324324
if !found {
325-
t.Fatalf("audit: %v", h.Audit)
325+
t.Fatalf("audit: %v", h.AuditLog())
326326
}
327327
}

‎cli/internal/svc/secrets/aws_test.go‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,12 @@ func expectErr(t *testing.T, h *awstest.Harness, code string, args ...string) {
5050

5151
func TestSecretsManagerCLI(t *testing.T) {
5252
h, sec, _, clk := setup(t)
53+
// Rotations run in the background; let them finish before the test ends.
54+
t.Cleanup(func() {
55+
for deadline := time.Now().Add(60 * time.Second); sec.Rotating() && time.Now().Before(deadline); {
56+
time.Sleep(50 * time.Millisecond)
57+
}
58+
})
5359
arnRe := regexp.MustCompile(`^arn:aws:secretsmanager:us-east-1:` + h.Env.AccountID + `:secret:app/db-[A-Za-z0-9]{6}$`)
5460

5561
c := h.AWSJSON(t, "secretsmanager", "create-secret", "--name", "app/db", "--secret-string", `{"user":"a","pass":"1"}`,
@@ -372,7 +378,7 @@ func TestSecretRotation(t *testing.T) {
372378
var mu sync.Mutex
373379
var steps []string
374380
failTest := false
375-
sec.Lambda = secrets.InvokerFunc(func(ctx context.Context, name string, payload []byte) ([]byte, string, error) {
381+
sec.SetLambda(secrets.InvokerFunc(func(ctx context.Context, name string, payload []byte) ([]byte, string, error) {
376382
var ev struct{ Step string }
377383
_ = json.Unmarshal(payload, &ev)
378384
mu.Lock()
@@ -390,7 +396,7 @@ func TestSecretRotation(t *testing.T) {
390396
return []byte(`{"errorMessage":` + strings.TrimSpace(strconvQuote(out.String())) + `}`), "Unhandled", nil
391397
}
392398
return []byte("null"), "", nil
393-
})
399+
}))
394400

395401
h.AWS(t, "secretsmanager", "create-secret", "--name", "rot", "--secret-string", "initial")
396402
fn := "arn:aws:lambda:us-east-1:" + h.Env.AccountID + ":function:rotator"

‎cli/internal/svc/secrets/native_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import (
1111
func TestSecretsNativeRotationPolicyStages(t *testing.T) {
1212
h, sec, _, _ := setup(t)
1313
sec.Routes(h.Router)
14-
sec.Lambda = secrets.InvokerFunc(func(context.Context, string, []byte) ([]byte, string, error) { return []byte("null"), "", nil })
14+
sec.SetLambda(secrets.InvokerFunc(func(context.Context, string, []byte) ([]byte, string, error) { return []byte("null"), "", nil }))
1515
get := func(method, path string, body any) map[string]any {
1616
var m map[string]any
1717
if err := json.Unmarshal(h.Native(t, method, path, body), &m); err != nil {

‎cli/internal/svc/secrets/rotation.go‎

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -103,7 +103,7 @@ func (s *Service) rotate(az Authz, ref string, in RotateInput) (Secret, string,
103103
if fn == "" {
104104
return sec, "", core.Errf(http.StatusBadRequest, "InvalidRequestException", "No Lambda rotation function ARN is associated with this secret.")
105105
}
106-
if s.Lambda == nil {
106+
if s.lambda() == nil {
107107
return sec, "", core.Errf(http.StatusBadRequest, "InvalidRequestException", "Lambda rotation functions are not available on this server.")
108108
}
109109
if !strings.HasPrefix(fn, "arn:") {
@@ -167,6 +167,13 @@ func (s *Service) pendingRotation(sec Secret) bool {
167167

168168
// startRotation adds the AWSPENDING placeholder version and runs the rotation
169169
// function's four steps in the background.
170+
// Rotating reports whether any rotation is still in progress.
171+
func (s *Service) Rotating() bool {
172+
s.rotMu.Lock()
173+
defer s.rotMu.Unlock()
174+
return len(s.rotating) > 0
175+
}
176+
170177
func (s *Service) startRotation(name, token string) (string, error) {
171178
if token == "" {
172179
token = uuid()
@@ -222,7 +229,12 @@ func (s *Service) runRotation(sec Secret, token string) {
222229
}
223230
for _, step := range []string{"createSecret", "setSecret", "testSecret", "finishSecret"} {
224231
payload, _ := json.Marshal(map[string]string{"Step": step, "SecretId": sec.ARN, "ClientRequestToken": token, "RotationToken": uuid()})
225-
out, fnErr, err := s.Lambda.Invoke(ctx, functionName(sec.RotationLambdaARN), payload)
232+
inv := s.lambda()
233+
if inv == nil {
234+
fail(step + ": Lambda rotation functions are not available on this server")
235+
return
236+
}
237+
out, fnErr, err := inv.Invoke(ctx, functionName(sec.RotationLambdaARN), payload)
226238
if err != nil {
227239
fail(fmt.Sprintf("%s: %s", step, errMessage(err)))
228240
return

‎cli/internal/svc/secrets/secrets.go‎

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ type Service struct {
118118
KMS KeyService
119119
// Lambda runs rotation functions; nil disables rotation.
120120
Lambda Invoker
121+
cfgMu sync.RWMutex // guards Lambda once the service is serving
121122

122123
rotMu sync.Mutex
123124
rotating map[string]bool
@@ -380,7 +381,7 @@ func (s *Service) PurgeExpired() {
380381
s.purgeDeleted()
381382
now := s.now()
382383
for _, sec := range store.List[Secret](s.env.Store, cSecrets) {
383-
if s.Lambda != nil && sec.DeletionDate == nil && sec.RotationEnabled && sec.NextRotation != nil && !now.Before(*sec.NextRotation) && !s.pendingRotation(sec) {
384+
if s.lambda() != nil && sec.DeletionDate == nil && sec.RotationEnabled && sec.NextRotation != nil && !now.Before(*sec.NextRotation) && !s.pendingRotation(sec) {
384385
_, _ = s.startRotation(sec.Name, "")
385386
}
386387
}
@@ -403,3 +404,16 @@ func uuid() string {
403404
h := fmt.Sprintf("%x", b)
404405
return h[0:8] + "-" + h[8:12] + "-" + h[12:16] + "-" + h[16:20] + "-" + h[20:32]
405406
}
407+
408+
// SetLambda replaces the rotation invoker while the service is running.
409+
func (s *Service) SetLambda(inv Invoker) {
410+
s.cfgMu.Lock()
411+
s.Lambda = inv
412+
s.cfgMu.Unlock()
413+
}
414+
415+
func (s *Service) lambda() Invoker {
416+
s.cfgMu.RLock()
417+
defer s.cfgMu.RUnlock()
418+
return s.Lambda
419+
}

‎cli/internal/svc/sns/aws_test.go‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@ func harness(t *testing.T) (*awstest.Harness, *Service, *fakeLambda) {
6060
s.Routes(h.Router)
6161
s.RegisterAWS()
6262
// Tests deliver to endpoints on 127.0.0.1.
63-
s.HTTP, s.CheckURL = &http.Client{Timeout: 5 * time.Second}, func(string) error { return nil }
63+
s.SetHTTP(&http.Client{Timeout: 5 * time.Second}, func(string) error { return nil })
6464
return h, s, fl
6565
}
6666

‎cli/internal/svc/sns/sns.go‎

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -118,6 +118,7 @@ type Service struct {
118118
// Both refuse loopback and link-local addresses by default.
119119
HTTP *http.Client
120120
CheckURL func(string) error
121+
cfgMu sync.RWMutex // guards HTTP and CheckURL once the service is serving
121122

122123
mu sync.Mutex
123124
dedup map[string]map[string]dedupEntry // FIFO topic -> dedup ID
@@ -463,7 +464,7 @@ func (s *Service) subscribe(t Topic, in subscribeInput, authorize func(action, r
463464
if err != nil || u.Scheme != in.Protocol || u.Host == "" {
464465
return Subscription{}, errInvalid("Endpoint must match the specified protocol")
465466
}
466-
if err := s.CheckURL(in.Endpoint); err != nil {
467+
if err := s.checkURL(in.Endpoint); err != nil {
467468
return Subscription{}, errInvalid("Endpoint: %v", err)
468469
}
469470
case "email", "email-json":
@@ -1049,7 +1050,7 @@ func (s *Service) post(sub Subscription, body []byte, typ, id string, raw bool)
10491050
if raw {
10501051
req.Header.Set("x-amz-sns-rawdelivery", "true")
10511052
}
1052-
resp, err := s.HTTP.Do(req)
1053+
resp, err := s.client().Do(req)
10531054
if err != nil {
10541055
return err
10551056
}
@@ -1063,12 +1064,12 @@ func (s *Service) post(sub Subscription, body []byte, typ, id string, raw bool)
10631064
// Notify delivers an alarm-style notification to a topic ARN or an http(s) URL.
10641065
func (s *Service) Notify(target, subject, message string) {
10651066
if strings.HasPrefix(target, "http://") || strings.HasPrefix(target, "https://") {
1066-
if err := s.CheckURL(target); err != nil {
1067+
if err := s.checkURL(target); err != nil {
10671068
log.Printf("notify %s: %v", target, err)
10681069
return
10691070
}
10701071
body, _ := json.Marshal(map[string]string{"subject": subject, "message": message})
1071-
resp, err := s.HTTP.Post(target, "application/json", bytes.NewReader(body))
1072+
resp, err := s.client().Post(target, "application/json", bytes.NewReader(body))
10721073
if err != nil {
10731074
log.Printf("notify %s: %v", target, err)
10741075
return
@@ -1415,3 +1416,23 @@ func (s *Service) cert(c *httpx.Ctx) (any, error) {
14151416
c.MarkWritten()
14161417
return nil, nil
14171418
}
1419+
1420+
// SetHTTP replaces the delivery client and URL check while the service is running.
1421+
func (s *Service) SetHTTP(c *http.Client, check func(string) error) {
1422+
s.cfgMu.Lock()
1423+
s.HTTP, s.CheckURL = c, check
1424+
s.cfgMu.Unlock()
1425+
}
1426+
1427+
func (s *Service) client() *http.Client {
1428+
s.cfgMu.RLock()
1429+
defer s.cfgMu.RUnlock()
1430+
return s.HTTP
1431+
}
1432+
1433+
func (s *Service) checkURL(u string) error {
1434+
s.cfgMu.RLock()
1435+
f := s.CheckURL
1436+
s.cfgMu.RUnlock()
1437+
return f(u)
1438+
}

‎cli/internal/svc/sqs/aws_test.go‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -442,11 +442,11 @@ func TestIAMDenied(t *testing.T) {
442442
t.Fatalf("send without permission: %v %s", err, out)
443443
}
444444
found := false
445-
for _, a := range h.Audit {
445+
for _, a := range h.AuditLog() {
446446
found = found || a == "sqs:SendMessage arn:aws:sqs:us-east-1:"+h.Env.AccountID+":locked"
447447
}
448448
if !found {
449-
t.Fatalf("audit %v", h.Audit)
449+
t.Fatalf("audit %v", h.AuditLog())
450450
}
451451
}
452452

0 commit comments

Comments
 (0)