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  }