github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/services/rollout-service/pkg/revolution/revolution.go (about) 1 /*This file is part of kuberpult. 2 3 Kuberpult is free software: you can redistribute it and/or modify 4 it under the terms of the Expat(MIT) License as published by 5 the Free Software Foundation. 6 7 Kuberpult is distributed in the hope that it will be useful, 8 but WITHOUT ANY WARRANTY; without even the implied warranty of 9 MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 10 MIT License for more details. 11 12 You should have received a copy of the MIT License 13 along with kuberpult. If not, see <https://directory.fsf.org/wiki/License:Expat>. 14 15 Copyright 2023 freiheit.com*/ 16 17 package revolution 18 19 import ( 20 "bytes" 21 "context" 22 "crypto/hmac" 23 "crypto/sha256" 24 "encoding/hex" 25 "encoding/json" 26 "fmt" 27 "io" 28 "net/http" 29 "time" 30 31 "github.com/freiheit-com/kuberpult/services/rollout-service/pkg/service" 32 "github.com/google/uuid" 33 "golang.org/x/sync/errgroup" 34 "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" 35 ) 36 37 type Config struct { 38 URL string 39 Token []byte 40 Concurrency int 41 MaxEventAge time.Duration 42 } 43 44 func New(config Config) *Subscriber { 45 sub := &Subscriber{ 46 token: nil, 47 url: "", 48 ready: nil, 49 state: nil, 50 maxAge: 0, 51 now: nil, 52 group: errgroup.Group{}, 53 } 54 sub.group.SetLimit(config.Concurrency) 55 sub.url = config.URL 56 sub.token = config.Token 57 sub.ready = func() {} 58 sub.maxAge = config.MaxEventAge 59 sub.now = time.Now 60 return sub 61 } 62 63 type Subscriber struct { 64 group errgroup.Group 65 token []byte 66 url string 67 // The ready function is needed to sync tests 68 ready func() 69 state map[service.Key]*service.BroadcastEvent 70 // The maximum age of events that should be considered. If 0, 71 // all events are considered. 72 maxAge time.Duration 73 // Used to simulate the current time in tests 74 now func() time.Time 75 } 76 77 func (s *Subscriber) Subscribe(ctx context.Context, b *service.Broadcast) error { 78 if s.state == nil { 79 s.state = map[service.Key]*service.BroadcastEvent{} 80 } 81 for { 82 err := s.subscribeOnce(ctx, b) 83 select { 84 case <-ctx.Done(): 85 return err 86 default: 87 } 88 } 89 } 90 91 func (s *Subscriber) subscribeOnce(ctx context.Context, b *service.Broadcast) error { 92 event, ch, unsub := b.Start() 93 defer unsub() 94 for _, ev := range event { 95 if ev.IsProduction != nil && *ev.IsProduction { 96 s.state[ev.Key] = ev 97 } 98 } 99 s.ready() 100 for { 101 select { 102 case <-ctx.Done(): 103 return s.group.Wait() 104 case ev, ok := <-ch: 105 if !ok { 106 return s.group.Wait() 107 } 108 if ev.IsProduction == nil || !*ev.IsProduction { 109 continue 110 } 111 if s.maxAge != 0 && 112 ev.ArgocdVersion != nil && 113 ev.ArgocdVersion.DeployedAt.Add(s.maxAge).Before(s.now()) { 114 continue 115 } 116 if shouldNotify(s.state[ev.Key], ev) { 117 s.group.Go(s.notify(ctx, ev)) 118 } 119 s.state[ev.Key] = ev 120 } 121 } 122 } 123 124 func shouldNotify(old *service.BroadcastEvent, nu *service.BroadcastEvent) bool { 125 // check for fields that must be present to generate the request 126 if nu.ArgocdVersion == nil || nu.IsProduction == nil || nu.ArgocdVersion.SourceCommitId == "" { 127 return false 128 } 129 if old == nil || old.ArgocdVersion == nil || old.IsProduction == nil { 130 return true 131 } 132 if old.ArgocdVersion.SourceCommitId != nu.ArgocdVersion.SourceCommitId || old.ArgocdVersion.DeployedAt != nu.ArgocdVersion.DeployedAt { 133 return true 134 } 135 return false 136 } 137 138 type kuberpultEvent struct { 139 Id string `json:"id"` 140 // Id/UUID to de-duplicate events 141 CommitHash string `json:"commitHash"` 142 EventTime string `json:"eventTime"` 143 // optimally in RFC3339 format 144 URL string `json:"url,omitempty"` 145 // where to see the logs/status of the deployment 146 ServiceName string `json:"serviceName"` 147 } 148 149 func (s *Subscriber) notify(ctx context.Context, ev *service.BroadcastEvent) func() error { 150 event := kuberpultEvent{ 151 URL: "", 152 Id: uuidFor(ev.Application, ev.ArgocdVersion.SourceCommitId, ev.ArgocdVersion.DeployedAt.String()), 153 CommitHash: ev.ArgocdVersion.SourceCommitId, 154 EventTime: ev.ArgocdVersion.DeployedAt.Format(time.RFC3339), 155 ServiceName: ev.Application, 156 } 157 return func() error { 158 span, _ := tracer.StartSpanFromContext(ctx, "revolution.notify") 159 defer span.Finish() 160 span.SetTag("revolution.url", s.url) 161 span.SetTag("revolution.id", event.Id) 162 span.SetTag("environment", ev.Environment) 163 span.SetTag("application", ev.Application) 164 body, err := json.Marshal(event) 165 if err != nil { 166 return fmt.Errorf("marshal event: %w", err) 167 } 168 h := hmac.New(sha256.New, s.token) 169 h.Write([]byte(body)) 170 sha := "sha256=" + hex.EncodeToString(h.Sum(nil)) 171 r, err := http.NewRequest(http.MethodPost, s.url, bytes.NewReader(body)) 172 if err != nil { 173 return fmt.Errorf("creating http request: %w", err) 174 } 175 r.Header.Set("Content-Type", "application/json") 176 r.Header.Set("X-Hub-Signature-256", sha) 177 r.Header.Set("User-Agent", "kuberpult") 178 s, err := http.DefaultClient.Do(r) 179 if err != nil { 180 span.Finish(tracer.WithError(err)) 181 return nil 182 } 183 span.SetTag("http.status_code", s.Status) 184 defer s.Body.Close() 185 content, _ := io.ReadAll(s.Body) 186 if s.StatusCode > 299 { 187 return fmt.Errorf("http status (%d): %s", s.StatusCode, content) 188 } 189 return nil 190 } 191 } 192 193 var kuberpultUuid uuid.UUID = uuid.NewSHA1(uuid.MustParse("00000000-0000-0000-0000-000000000000"), []byte("kuberpult")) 194 195 func uuidFor(application, commitHash, deployedAt string) string { 196 return uuid.NewSHA1(kuberpultUuid, []byte(fmt.Sprintf("%s\n%s\n%s", application, commitHash, deployedAt))).String() 197 }