github.com/freiheit-com/kuberpult@v1.24.2-0.20240328135542-315d5630abe6/services/cd-service/pkg/repository/repository.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 repository 18 19 import ( 20 "bufio" 21 "bytes" 22 "context" 23 "crypto/tls" 24 "encoding/json" 25 "errors" 26 "fmt" 27 "io" 28 "net/http" 29 "os" 30 "os/exec" 31 "path/filepath" 32 "regexp" 33 "sort" 34 "strconv" 35 "strings" 36 "sync" 37 "time" 38 39 "github.com/freiheit-com/kuberpult/pkg/grpc" 40 "google.golang.org/protobuf/types/known/timestamppb" 41 42 v1alpha1 "github.com/freiheit-com/kuberpult/services/cd-service/pkg/argocd/v1alpha1" 43 "github.com/freiheit-com/kuberpult/services/cd-service/pkg/mapper" 44 45 "github.com/DataDog/datadog-go/v5/statsd" 46 backoff "github.com/cenkalti/backoff/v4" 47 api "github.com/freiheit-com/kuberpult/pkg/api/v1" 48 "github.com/freiheit-com/kuberpult/pkg/auth" 49 "github.com/freiheit-com/kuberpult/pkg/setup" 50 "github.com/freiheit-com/kuberpult/services/cd-service/pkg/argocd" 51 "github.com/freiheit-com/kuberpult/services/cd-service/pkg/config" 52 "github.com/freiheit-com/kuberpult/services/cd-service/pkg/fs" 53 "github.com/freiheit-com/kuberpult/services/cd-service/pkg/notify" 54 "github.com/freiheit-com/kuberpult/services/cd-service/pkg/sqlitestore" 55 "go.uber.org/zap" 56 "gopkg.in/DataDog/dd-trace-go.v1/ddtrace/tracer" 57 58 "github.com/freiheit-com/kuberpult/pkg/logger" 59 billy "github.com/go-git/go-billy/v5" 60 "github.com/go-git/go-billy/v5/util" 61 git "github.com/libgit2/git2go/v34" 62 ) 63 64 type contextKey string 65 66 const DdMetricsKey contextKey = "ddMetrics" 67 68 // A Repository provides a multiple reader / single writer access to a git repository. 69 type Repository interface { 70 Apply(ctx context.Context, transformers ...Transformer) error 71 Push(ctx context.Context, pushAction func() error) error 72 ApplyTransformersInternal(ctx context.Context, transformers ...Transformer) ([]string, *State, []*TransformerResult, *TransformerBatchApplyError) 73 State() *State 74 StateAt(oid *git.Oid) (*State, error) 75 Notify() *notify.Notify 76 } 77 78 type TransformerBatchApplyError struct { 79 TransformerError error // the error that caused the batch to fail. nil if no error happened 80 Index int // the index of the transformer that caused the batch to fail or -1 if the error happened outside one specific transformer 81 } 82 83 func (err *TransformerBatchApplyError) Error() string { 84 if err == nil { 85 return "" 86 } 87 if err.Index < 0 { 88 return fmt.Sprintf("error not specific to one transformer of this batch: %s", err.TransformerError.Error()) 89 } 90 return fmt.Sprintf("error at index %d of transformer batch: %s", err.Index, err.TransformerError.Error()) 91 } 92 93 func (err *TransformerBatchApplyError) Is(target error) bool { 94 tgt, ok := target.(*TransformerBatchApplyError) 95 if !ok { 96 return false 97 } 98 if err == nil { 99 return target == nil 100 } 101 if target == nil { 102 return false 103 } 104 // now both target and err are guaranteed to be non-nil 105 if err.Index != tgt.Index { 106 return false 107 } 108 return errors.Is(err.TransformerError, tgt.TransformerError) 109 } 110 111 func defaultBackOffProvider() backoff.BackOff { 112 eb := backoff.NewExponentialBackOff() 113 eb.MaxElapsedTime = 7 * time.Second 114 return backoff.WithMaxRetries(eb, 6) 115 } 116 117 var ( 118 ddMetrics statsd.ClientInterface 119 ) 120 121 type StorageBackend int 122 123 const ( 124 DefaultBackend StorageBackend = 0 125 GitBackend StorageBackend = iota 126 SqliteBackend StorageBackend = iota 127 ) 128 129 const ( 130 maxArgoRequests = 3 // note that this happens inside a request, we cannot retry too much! 131 ) 132 133 type repository struct { 134 // Mutex gurading the writer 135 writeLock sync.Mutex 136 writesDone uint 137 queue queue 138 config *RepositoryConfig 139 credentials *credentialsStore 140 certificates *certificateStore 141 142 repository *git.Repository 143 144 // Mutex guarding head 145 headLock sync.Mutex 146 147 notify notify.Notify 148 149 backOffProvider func() backoff.BackOff 150 } 151 152 type WebhookResolver interface { 153 Resolve(insecure bool, req *http.Request) (*http.Response, error) 154 } 155 156 type DefaultWebhookResolver struct{} 157 158 func (r DefaultWebhookResolver) Resolve(insecure bool, req *http.Request) (*http.Response, error) { 159 //exhaustruct:ignore 160 TLSClientConfig := &tls.Config{ 161 InsecureSkipVerify: insecure, 162 } 163 //exhaustruct:ignore 164 tr := &http.Transport{ 165 TLSClientConfig: TLSClientConfig, 166 } 167 //exhaustruct:ignore 168 client := &http.Client{ 169 Transport: tr, 170 } 171 return client.Do(req) 172 } 173 174 type RepositoryConfig struct { 175 // Mandatory Config 176 // the URL used for git checkout, (ssh protocol) 177 URL string 178 Path string 179 // Optional Config 180 Credentials Credentials 181 Certificates Certificates 182 CommitterEmail string 183 CommitterName string 184 // default branch is master 185 Branch string 186 // network timeout 187 NetworkTimeout time.Duration 188 // 189 GcFrequency uint 190 StorageBackend StorageBackend 191 // Bootstrap mode controls where configurations are read from 192 // true: read from json file at EnvironmentConfigsPath 193 // false: read from config files in manifest repo 194 BootstrapMode bool 195 EnvironmentConfigsPath string 196 ArgoInsecure bool 197 // if set, kuberpult will generate push events to argoCd whenever it writes to the manifest repo: 198 ArgoWebhookUrl string 199 // the url to the git repo, like the browser requires it (https protocol) 200 WebURL string 201 DogstatsdEvents bool 202 WriteCommitData bool 203 WebhookResolver WebhookResolver 204 205 MaximumCommitsPerPush uint 206 207 MaximumQueueSize uint 208 } 209 210 func openOrCreate(path string, storageBackend StorageBackend) (*git.Repository, error) { 211 repo2, err := git.OpenRepositoryExtended(path, git.RepositoryOpenNoSearch, path) 212 if err != nil { 213 var gerr *git.GitError 214 if errors.As(err, &gerr) { 215 if gerr.Code == git.ErrorCodeNotFound { 216 err = os.MkdirAll(path, 0777) 217 if err != nil { 218 return nil, err 219 } 220 repo2, err = git.InitRepository(path, true) 221 if err != nil { 222 return nil, err 223 } 224 } else { 225 return nil, err 226 } 227 } else { 228 return nil, err 229 } 230 } 231 if storageBackend == SqliteBackend { 232 sqlitePath := filepath.Join(path, "odb.sqlite") 233 be, err := sqlitestore.NewOdbBackend(sqlitePath) 234 if err != nil { 235 return nil, fmt.Errorf("creating odb backend: %w", err) 236 } 237 odb, err := repo2.Odb() 238 if err != nil { 239 return nil, fmt.Errorf("gettting odb: %w", err) 240 } 241 // Prioriority 99 ensures that libgit prefers this backend for writing over its buildin backends. 242 err = odb.AddBackend(be, 99) 243 if err != nil { 244 return nil, fmt.Errorf("setting odb backend: %w", err) 245 } 246 } 247 return repo2, err 248 } 249 250 func GetTags(cfg RepositoryConfig, repoName string, ctx context.Context) (tags []*api.TagData, err error) { 251 repo, err := openOrCreate(repoName, cfg.StorageBackend) 252 if err != nil { 253 return nil, fmt.Errorf("unable to open/create repo: %v", err) 254 } 255 256 var credentials *credentialsStore 257 var certificates *certificateStore 258 if strings.HasPrefix(cfg.URL, "./") || strings.HasPrefix(cfg.URL, "/") { 259 } else { 260 credentials, err = cfg.Credentials.load() 261 if err != nil { 262 return nil, fmt.Errorf("failure to load credentials: %v", err) 263 } 264 certificates, err = cfg.Certificates.load() 265 if err != nil { 266 return nil, fmt.Errorf("failure to load certificates: %v", err) 267 } 268 } 269 270 fetchSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", cfg.Branch, cfg.Branch) 271 //exhaustruct:ignore 272 RemoteCallbacks := git.RemoteCallbacks{ 273 CredentialsCallback: credentials.CredentialsCallback(ctx), 274 CertificateCheckCallback: certificates.CertificateCheckCallback(ctx), 275 } 276 fetchOptions := git.FetchOptions{ 277 Prune: git.FetchPruneUnspecified, 278 UpdateFetchhead: false, 279 Headers: nil, 280 ProxyOptions: git.ProxyOptions{ 281 Type: git.ProxyTypeNone, 282 Url: "", 283 }, 284 RemoteCallbacks: RemoteCallbacks, 285 DownloadTags: git.DownloadTagsAll, 286 } 287 remote, err := repo.Remotes.CreateAnonymous(cfg.URL) 288 if err != nil { 289 return nil, fmt.Errorf("failure to create anonymous remote: %v", err) 290 } 291 err = remote.Fetch([]string{fetchSpec}, &fetchOptions, "fetching") 292 if err != nil { 293 return nil, fmt.Errorf("failure to fetch: %v", err) 294 } 295 296 tagsList, err := repo.Tags.List() 297 if err != nil { 298 return nil, fmt.Errorf("unable to list tags: %v", err) 299 } 300 301 sort.Strings(tagsList) 302 iters, err := repo.NewReferenceIteratorGlob("refs/tags/*") 303 if err != nil { 304 return nil, fmt.Errorf("unable to get list of tags: %v", err) 305 } 306 for { 307 tagObject, err := iters.Next() 308 if err != nil { 309 break 310 } 311 tagRef, lookupErr := repo.LookupTag(tagObject.Target()) 312 if lookupErr != nil { 313 tagCommit, err := repo.LookupCommit(tagObject.Target()) 314 // If LookupTag fails, fallback to LookupCommit 315 // to cover all tags, annotated and lightweight 316 if err != nil { 317 return nil, fmt.Errorf("unable to lookup tag [%s]: %v - original err: %v", tagObject.Name(), err, lookupErr) 318 } 319 tags = append(tags, &api.TagData{Tag: tagObject.Name(), CommitId: tagCommit.Id().String()}) 320 } else { 321 tags = append(tags, &api.TagData{Tag: tagObject.Name(), CommitId: tagRef.Id().String()}) 322 } 323 } 324 325 return tags, nil 326 } 327 328 // Opens a repository. The repository is initialized and updated in the background. 329 func New(ctx context.Context, cfg RepositoryConfig) (Repository, error) { 330 repo, bg, err := New2(ctx, cfg) 331 if err != nil { 332 return nil, err 333 } 334 go bg(ctx, nil) //nolint: errcheck 335 return repo, err 336 } 337 338 func New2(ctx context.Context, cfg RepositoryConfig) (Repository, setup.BackgroundFunc, error) { 339 logger := logger.FromContext(ctx) 340 341 ddMetricsFromCtx := ctx.Value(DdMetricsKey) 342 if ddMetricsFromCtx != nil { 343 ddMetrics = ddMetricsFromCtx.(statsd.ClientInterface) 344 } else { 345 logger.Sugar().Warnf("could not load ddmetrics from context - running without datadog metrics") 346 } 347 348 if cfg.Branch == "" { 349 cfg.Branch = "master" 350 } 351 if cfg.CommitterEmail == "" { 352 cfg.CommitterEmail = "kuberpult@example.com" 353 } 354 if cfg.CommitterName == "" { 355 cfg.CommitterName = "kuberpult" 356 } 357 if cfg.StorageBackend == DefaultBackend { 358 cfg.StorageBackend = SqliteBackend 359 } 360 if cfg.NetworkTimeout == 0 { 361 cfg.NetworkTimeout = time.Minute 362 } 363 if cfg.MaximumCommitsPerPush == 0 { 364 cfg.MaximumCommitsPerPush = 1 365 366 } 367 if cfg.MaximumQueueSize == 0 { 368 cfg.MaximumQueueSize = 5 369 } 370 371 var credentials *credentialsStore 372 var certificates *certificateStore 373 var err error 374 if strings.HasPrefix(cfg.URL, "./") || strings.HasPrefix(cfg.URL, "/") { 375 logger.Debug("git url indicates a local directory. Ignoring credentials and certificates.") 376 } else { 377 credentials, err = cfg.Credentials.load() 378 if err != nil { 379 return nil, nil, err 380 } 381 certificates, err = cfg.Certificates.load() 382 if err != nil { 383 return nil, nil, err 384 } 385 } 386 387 if repo2, err := openOrCreate(cfg.Path, cfg.StorageBackend); err != nil { 388 return nil, nil, err 389 } else { 390 // configure remotes 391 if remote, err := repo2.Remotes.CreateAnonymous(cfg.URL); err != nil { 392 return nil, nil, err 393 } else { 394 result := &repository{ 395 writesDone: 0, 396 headLock: sync.Mutex{}, 397 notify: notify.Notify{}, 398 writeLock: sync.Mutex{}, 399 config: &cfg, 400 credentials: credentials, 401 certificates: certificates, 402 repository: repo2, 403 queue: makeQueueN(cfg.MaximumQueueSize), 404 backOffProvider: defaultBackOffProvider, 405 } 406 result.headLock.Lock() 407 408 defer result.headLock.Unlock() 409 fetchSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", cfg.Branch, cfg.Branch) 410 //exhaustruct:ignore 411 RemoteCallbacks := git.RemoteCallbacks{ 412 UpdateTipsCallback: func(refname string, a *git.Oid, b *git.Oid) error { 413 logger.Debug("git.fetched", 414 zap.String("refname", refname), 415 zap.String("revision.new", b.String()), 416 ) 417 return nil 418 }, 419 CredentialsCallback: credentials.CredentialsCallback(ctx), 420 CertificateCheckCallback: certificates.CertificateCheckCallback(ctx), 421 } 422 fetchOptions := git.FetchOptions{ 423 Prune: git.FetchPruneUnspecified, 424 UpdateFetchhead: false, 425 DownloadTags: git.DownloadTagsUnspecified, 426 Headers: nil, 427 ProxyOptions: git.ProxyOptions{ 428 Type: git.ProxyTypeNone, 429 Url: "", 430 }, 431 RemoteCallbacks: RemoteCallbacks, 432 } 433 err := remote.Fetch([]string{fetchSpec}, &fetchOptions, "fetching") 434 if err != nil { 435 return nil, nil, err 436 } 437 var rev *git.Oid 438 if remoteRef, err := repo2.References.Lookup(fmt.Sprintf("refs/remotes/origin/%s", cfg.Branch)); err != nil { 439 var gerr *git.GitError 440 if errors.As(err, &gerr) && gerr.Code == git.ErrorCodeNotFound { 441 // not found 442 // nothing to do 443 } else { 444 return nil, nil, err 445 } 446 } else { 447 rev = remoteRef.Target() 448 if _, err := repo2.References.Create(fmt.Sprintf("refs/heads/%s", cfg.Branch), rev, true, "reset branch"); err != nil { 449 return nil, nil, err 450 } 451 } 452 453 // check that we can build the current state 454 state, err := result.StateAt(nil) 455 if err != nil { 456 return nil, nil, err 457 } 458 459 // Check configuration for errors and abort early if any: 460 _, err = state.GetEnvironmentConfigsAndValidate(ctx) 461 if err != nil { 462 return nil, nil, err 463 } 464 465 return result, result.ProcessQueue, nil 466 } 467 } 468 } 469 470 func (r *repository) ProcessQueue(ctx context.Context, health *setup.HealthReporter) error { 471 defer func() { 472 close(r.queue.transformerBatches) 473 for e := range r.queue.transformerBatches { 474 e.finish(ctx.Err()) 475 } 476 }() 477 tick := time.Tick(r.config.NetworkTimeout) //nolint: staticcheck 478 ttl := r.config.NetworkTimeout * 3 479 for { 480 /* 481 One tricky issue is that `git push` can take a while depending on the git hoster and the connection 482 (plus we do have relatively big and many commits). 483 This can lead to the situation that "everything hangs", because there is one push running already - 484 but only one push is possible at a time. 485 There is also no good way to cancel a `git push`. 486 487 To circumvent this, we report health with a "time to live" - meaning if we don't report anything within the time frame, 488 the health will turn to "failed" and then the pod will automatically restart (in kubernetes). 489 */ 490 health.ReportHealthTtl(setup.HealthReady, "processing queue", &ttl) 491 select { 492 case <-tick: 493 // this triggers a for loop every `NetworkTimeout` to refresh the readiness 494 case <-ctx.Done(): 495 return nil 496 case e := <-r.queue.transformerBatches: 497 r.ProcessQueueOnce(ctx, e, defaultPushUpdate, DefaultPushActionCallback) 498 } 499 } 500 } 501 502 func (r *repository) applyTransformerBatches(transformerBatches []transformerBatch, allowFetchAndReset bool) ([]transformerBatch, error, *TransformerResult) { 503 //exhaustruct:ignore 504 var changes = &TransformerResult{} 505 for i := 0; i < len(transformerBatches); { 506 e := transformerBatches[i] 507 subChanges, applyErr := r.ApplyTransformers(e.ctx, e.transformers...) 508 changes.Combine(subChanges) 509 if applyErr != nil { 510 if errors.Is(applyErr.TransformerError, InvalidJson) && allowFetchAndReset { 511 // Invalid state. fetch and reset and redo 512 err := r.FetchAndReset(e.ctx) 513 if err != nil { 514 return transformerBatches, err, nil 515 } 516 return r.applyTransformerBatches(transformerBatches, false) 517 } else { 518 e.finish(applyErr) 519 // here, we keep all transformerBatches "behind i". 520 // these are the transformerBatches that have not been applied yet 521 transformerBatches = append(transformerBatches[:i], transformerBatches[i+1:]...) 522 } 523 } else { 524 i++ 525 } 526 } 527 return transformerBatches, nil, changes 528 } 529 530 var panicError = errors.New("Panic") 531 532 func (r *repository) useRemote(callback func(*git.Remote) error) error { 533 remote, err := r.repository.Remotes.CreateAnonymous(r.config.URL) 534 if err != nil { 535 return fmt.Errorf("opening remote %q: %w", r.config.URL, err) 536 } 537 ctx, cancel := context.WithTimeout(context.Background(), r.config.NetworkTimeout) 538 defer cancel() 539 errCh := make(chan error, 1) 540 go func() { 541 // Usually we call `defer` right after resource allocation (`CreateAnonymous`). 542 // The issue with that is that the `callback` requires the remote, and cannot be cancelled properly. 543 // So `callback` may run longer than `useRemote`, and if at that point `Disconnect` was already called, we get a `panic`. 544 defer remote.Disconnect() 545 errCh <- callback(remote) 546 }() 547 select { 548 case <-ctx.Done(): 549 return ctx.Err() 550 case err := <-errCh: 551 return err 552 } 553 } 554 555 func (r *repository) drainQueue() []transformerBatch { 556 if r.config.MaximumCommitsPerPush < 2 { 557 return nil 558 } 559 limit := r.config.MaximumCommitsPerPush - 1 560 transformerBatches := []transformerBatch{} 561 for uint(len(transformerBatches)) < limit { 562 select { 563 case f := <-r.queue.transformerBatches: 564 // Check that the item is not already cancelled 565 GaugeQueueSize(f.ctx, len(r.queue.transformerBatches)) 566 select { 567 case <-f.ctx.Done(): 568 f.finish(f.ctx.Err()) 569 default: 570 transformerBatches = append(transformerBatches, f) 571 } 572 default: 573 return transformerBatches 574 } 575 } 576 return transformerBatches 577 } 578 579 // It returns always nil 580 // success is set to true if the push was successful 581 func defaultPushUpdate(branch string, success *bool) git.PushUpdateReferenceCallback { 582 return func(refName string, status string) error { 583 var expectedRefName = fmt.Sprintf("refs/heads/%s", branch) 584 // if we were successful the status is empty and the ref contains our branch: 585 *success = refName == expectedRefName && status == "" 586 return nil 587 } 588 } 589 590 type PushActionFunc func() error 591 type PushActionCallbackFunc func(git.PushOptions, *repository) PushActionFunc 592 593 // DefaultPushActionCallback is public for testing reasons only. 594 func DefaultPushActionCallback(pushOptions git.PushOptions, r *repository) PushActionFunc { 595 return func() error { 596 return r.useRemote(func(remote *git.Remote) error { 597 return remote.Push([]string{fmt.Sprintf("refs/heads/%s:refs/heads/%s", r.config.Branch, r.config.Branch)}, &pushOptions) 598 }) 599 } 600 } 601 602 type PushUpdateFunc func(string, *bool) git.PushUpdateReferenceCallback 603 604 func (r *repository) ProcessQueueOnce(ctx context.Context, e transformerBatch, callback PushUpdateFunc, pushAction PushActionCallbackFunc) { 605 logger := logger.FromContext(ctx) 606 var err error = panicError 607 608 // Check that the first transformerBatch is not already canceled 609 select { 610 case <-e.ctx.Done(): 611 e.finish(e.ctx.Err()) 612 return 613 default: 614 } 615 616 transformerBatches := []transformerBatch{e} 617 defer func() { 618 for _, el := range transformerBatches { 619 el.finish(err) 620 } 621 }() 622 623 // Try to fetch more items from the queue in order to push more things together 624 transformerBatches = append(transformerBatches, r.drainQueue()...) 625 626 var pushSuccess = true 627 628 //exhaustruct:ignore 629 RemoteCallbacks := git.RemoteCallbacks{ 630 CredentialsCallback: r.credentials.CredentialsCallback(e.ctx), 631 CertificateCheckCallback: r.certificates.CertificateCheckCallback(e.ctx), 632 PushUpdateReferenceCallback: callback(r.config.Branch, &pushSuccess), 633 } 634 pushOptions := git.PushOptions{ 635 PbParallelism: 0, 636 Headers: nil, 637 ProxyOptions: git.ProxyOptions{ 638 Type: git.ProxyTypeNone, 639 Url: "", 640 }, 641 RemoteCallbacks: RemoteCallbacks, 642 } 643 644 // Apply the items 645 transformerBatches, err, changes := r.applyTransformerBatches(transformerBatches, true) 646 if err != nil { 647 return 648 } 649 650 if len(transformerBatches) == 0 { 651 return 652 } 653 654 // Try pushing once 655 err = r.Push(e.ctx, pushAction(pushOptions, r)) 656 if err != nil { 657 gerr, ok := err.(*git.GitError) 658 // If it doesn't work because the branch diverged, try reset and apply again. 659 if ok && gerr.Code == git.ErrorCodeNonFastForward { 660 err = r.FetchAndReset(e.ctx) 661 if err != nil { 662 return 663 } 664 // Apply the items 665 transformerBatches, err, changes = r.applyTransformerBatches(transformerBatches, false) 666 if err != nil || len(transformerBatches) == 0 { 667 return 668 } 669 if pushErr := r.Push(e.ctx, pushAction(pushOptions, r)); pushErr != nil { 670 err = pushErr 671 } 672 } else if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { 673 err = grpc.CanceledError(ctx, err) 674 } else { 675 logger.Error(fmt.Sprintf("error while pushing: %s", err)) 676 err = grpc.PublicError(ctx, fmt.Errorf("could not push to manifest repository '%s' on branch '%s' - this indicates that the ssh key does not have write access", r.config.URL, r.config.Branch)) 677 } 678 } else { 679 if !pushSuccess { 680 err = fmt.Errorf("failed to push - this indicates that branch protection is enabled in '%s' on branch '%s'", r.config.URL, r.config.Branch) 681 } 682 } 683 span, ctx := tracer.StartSpanFromContext(e.ctx, "PostPush") 684 defer span.Finish() 685 686 ddSpan, ctx := tracer.StartSpanFromContext(ctx, "SendMetrics") 687 if r.config.DogstatsdEvents { 688 ddError := UpdateDatadogMetrics(ctx, r.State(), changes, time.Now()) 689 if ddError != nil { 690 logger.Warn(fmt.Sprintf("Could not send datadog metrics/events %v", ddError)) 691 } 692 } 693 ddSpan.Finish() 694 695 if r.config.ArgoWebhookUrl != "" { 696 r.sendWebhookToArgoCd(ctx, logger, changes) 697 } 698 699 r.notify.Notify() 700 } 701 702 func (r *repository) sendWebhookToArgoCd(ctx context.Context, logger *zap.Logger, changes *TransformerResult) { 703 var modified = []string{} 704 for i := range changes.ChangedApps { 705 change := changes.ChangedApps[i] 706 // we may need to add the root app in some circumstances - so far it doesn't seem necessary, so we just add the manifest.yaml: 707 manifestFilename := fmt.Sprintf("environments/%s/applications/%s/manifests/manifests.yaml", change.Env, change.App) 708 modified = append(modified, manifestFilename) 709 logger.Info(fmt.Sprintf("ArgoWebhookUrl: adding modified: %s", manifestFilename)) 710 } 711 var deleted = []string{} 712 for i := range changes.DeletedRootApps { 713 change := changes.DeletedRootApps[i] 714 // we may need to add the root app in some circumstances - so far it doesn't seem necessary, so we just add the manifest.yaml: 715 rootAppFilename := fmt.Sprintf("argocd/%s/%s.yaml", "v1alpha1", change.Env) 716 deleted = append(deleted, rootAppFilename) 717 logger.Info(fmt.Sprintf("ArgoWebhookUrl: adding modified: %s", rootAppFilename)) 718 } 719 720 argoResult := ArgoWebhookData{ 721 htmlUrl: r.config.WebURL, // if this does not match, argo will completely ignore the request and return 200 722 revision: "refs/heads/" + r.config.Branch, 723 change: changeInfo{ 724 payloadBefore: "", 725 payloadAfter: changes.Commits.Current.String(), 726 }, 727 defaultBranch: r.config.Branch, // this is questionable, because we don't actually know the default branch, but it seems to work fine in practice 728 Commits: []commit{ 729 { 730 Added: []string{}, 731 Modified: modified, 732 Removed: deleted, 733 }, 734 }, 735 } 736 if changes.Commits.Previous != nil { 737 argoResult.change.payloadBefore = changes.Commits.Previous.String() 738 } 739 740 span, ctx := tracer.StartSpanFromContext(ctx, "Webhook-Retries") 741 defer span.Finish() 742 success := false 743 var err error = nil 744 for i := 1; i <= maxArgoRequests; i++ { 745 err, shouldRetry := doWebhookPostRequest(ctx, argoResult, r.config, i) 746 if err != nil && shouldRetry { 747 logger.Warn(fmt.Sprintf("ProcessQueueOnce: error sending webhook on try %d: %v", i, err)) 748 if shouldRetry { 749 // we're still in a request here, we can't wait too long: 750 time.Sleep(time.Duration(100*i) * time.Millisecond) 751 } else { 752 break 753 } 754 } else { 755 logger.Info(fmt.Sprintf("ProcessQueueOnce: argo webhook was send successfully on try %d!", i)) 756 success = true 757 break 758 } 759 } 760 span.SetTag("success", success) 761 if !success { 762 logger.Error(fmt.Sprintf("ProcessQueueOnce: error sending webhook after all %d tries: %v", maxArgoRequests, err)) 763 } 764 } 765 766 func contains(s []int, e int) bool { 767 for _, a := range s { 768 if a == e { 769 return true 770 } 771 } 772 return false 773 } 774 775 func doWebhookPostRequest(ctx context.Context, data ArgoWebhookData, repoConfig *RepositoryConfig, retryCounter int) (error, bool) { 776 span, ctx := tracer.StartSpanFromContext(ctx, "Webhook") 777 span.SetTag("changeAfter", data.change.payloadAfter) 778 span.SetTag("changeBefore", data.change.payloadBefore) 779 span.SetTag("try", retryCounter) 780 defer span.Finish() 781 url := repoConfig.ArgoWebhookUrl + "/api/webhook" 782 l := logger.FromContext(ctx) 783 l.Info(fmt.Sprintf("doWebhookPostRequest: URL: %s", url)) 784 785 //exhaustruct:ignore 786 Repository := v1alpha1.Repository{ 787 HTMLURL: data.htmlUrl, 788 DefaultBranch: data.defaultBranch, 789 } 790 //exhaustruct:ignore 791 var argoFormat = v1alpha1.PushPayload{ 792 Ref: data.revision, 793 Before: data.change.payloadBefore, 794 After: data.change.payloadAfter, 795 Repository: Repository, 796 Commits: toArgoCommits(data.Commits), 797 } 798 799 jsonBytes, err := json.MarshalIndent(argoFormat, " ", " ") 800 if err != nil { 801 return err, false 802 } 803 l.Info(fmt.Sprintf("doWebhookPostRequest argo format: %s", string(jsonBytes))) 804 req, err := http.NewRequest("POST", url, bytes.NewBuffer(jsonBytes)) 805 if err != nil { 806 return fmt.Errorf("Could not create new request: %s", err.Error()), false 807 } 808 req.Header.Set("Content-Type", "application/json") 809 810 // now pretend that we are GitHub by adding this header, otherwise argo will ignore our request: 811 req.Header.Set("X-GitHub-Event", "push") 812 813 var webhookResolver WebhookResolver = DefaultWebhookResolver{} 814 if repoConfig.WebhookResolver != nil { 815 webhookResolver = repoConfig.WebhookResolver 816 } 817 resp, err := webhookResolver.Resolve(repoConfig.ArgoInsecure, req) 818 if err != nil { 819 return fmt.Errorf("doWebhookPostRequest: could not send request to '%s': %s", url, err.Error()), false 820 } 821 defer resp.Body.Close() 822 823 //l.Warn(fmt.Sprintf("response Status: %d", resp.StatusCode)) 824 l.Info(fmt.Sprintf("response headers: %s", resp.Header)) 825 body, err := io.ReadAll(resp.Body) 826 if err != nil { 827 // weird but we kinda do not care about the body: 828 l.Warn(fmt.Sprintf("doWebhookPostRequest: could not read body: %s - continuing anyway", err.Error())) 829 } 830 validResponseCodes := []int{200} 831 if resp.StatusCode >= 500 { 832 return fmt.Errorf("doWebhookPostRequest: invalid status code from argo: %d", resp.StatusCode), true 833 } 834 835 if contains(validResponseCodes, resp.StatusCode) { 836 l.Info(fmt.Sprintf("doWebhookPostRequest: response Body: %s", string(body))) 837 return nil, false 838 } 839 // in any other case we should not do a retry (e.g. status 4xx): 840 l.Warn(fmt.Sprintf("doWebhookPostRequest: response Body: %s", string(body))) 841 return fmt.Errorf("doWebhookPostRequest: invalid status code from argo: %d", resp.StatusCode), false 842 } 843 844 func toArgoCommits(commits []commit) []v1alpha1.Commit { 845 var result = []v1alpha1.Commit{} 846 for i := range commits { 847 c := commits[i] 848 result = append(result, v1alpha1.Commit{ 849 // ArgoCd ignores most fields, so we can ignore them too. 850 // Source: function "affectedRevisionInfo" in https://github.com/argoproj/argo-cd/blob/master/util/webhook/webhook.go#L141 851 Sha: "", 852 ID: "", 853 NodeID: "", 854 TreeID: "", 855 Distinct: false, 856 Message: "", 857 Timestamp: "", 858 URL: "", 859 Author: struct { 860 Name string `json:"name"` 861 Email string `json:"email"` 862 Username string `json:"username"` 863 }{ 864 Name: "", 865 Email: "", 866 Username: "", 867 }, 868 Committer: struct { 869 Name string `json:"name"` 870 Email string `json:"email"` 871 Username string `json:"username"` 872 }{ 873 Name: "", 874 Email: "", 875 Username: "", 876 }, 877 Added: c.Added, 878 Removed: c.Removed, 879 Modified: c.Modified, 880 }) 881 } 882 return result 883 } 884 885 type changeInfo struct { 886 payloadBefore string 887 payloadAfter string 888 } 889 type commit struct { 890 Added []string 891 Modified []string 892 Removed []string 893 } 894 895 type ArgoWebhookData struct { 896 htmlUrl string 897 revision string // aka "ref" 898 change changeInfo 899 defaultBranch string 900 Commits []commit 901 } 902 903 func (r *repository) ApplyTransformersInternal(ctx context.Context, transformers ...Transformer) ([]string, *State, []*TransformerResult, *TransformerBatchApplyError) { 904 if state, err := r.StateAt(nil); err != nil { 905 return nil, nil, nil, &TransformerBatchApplyError{TransformerError: fmt.Errorf("%s: %w", "failure in StateAt", err), Index: -1} 906 } else { 907 var changes []*TransformerResult = nil 908 commitMsg := []string{} 909 ctxWithTime := WithTimeNow(ctx, time.Now()) 910 for i, t := range transformers { 911 if msg, subChanges, err := RunTransformer(ctxWithTime, t, state); err != nil { 912 applyErr := TransformerBatchApplyError{ 913 TransformerError: err, 914 Index: i, 915 } 916 return nil, nil, nil, &applyErr 917 } else { 918 commitMsg = append(commitMsg, msg) 919 changes = append(changes, subChanges) 920 } 921 } 922 return commitMsg, state, changes, nil 923 } 924 } 925 926 type AppEnv struct { 927 App string 928 Env string 929 Team string 930 } 931 932 type RootApp struct { 933 Env string 934 //argocd/v1alpha1/development2.yaml 935 } 936 937 type TransformerResult struct { 938 ChangedApps []AppEnv 939 DeletedRootApps []RootApp 940 Commits *CommitIds 941 } 942 943 type CommitIds struct { 944 Previous *git.Oid 945 Current *git.Oid 946 } 947 948 func (r *TransformerResult) AddAppEnv(app string, env string, team string) { 949 r.ChangedApps = append(r.ChangedApps, AppEnv{ 950 App: app, 951 Env: env, 952 Team: team, 953 }) 954 } 955 956 func (r *TransformerResult) AddRootApp(env string) { 957 r.DeletedRootApps = append(r.DeletedRootApps, RootApp{ 958 Env: env, 959 }) 960 } 961 962 func (r *TransformerResult) Combine(other *TransformerResult) { 963 if other == nil { 964 return 965 } 966 for i := range other.ChangedApps { 967 a := other.ChangedApps[i] 968 r.AddAppEnv(a.App, a.Env, a.Team) 969 } 970 for i := range other.DeletedRootApps { 971 a := other.DeletedRootApps[i] 972 r.AddRootApp(a.Env) 973 } 974 if r.Commits == nil { 975 r.Commits = other.Commits 976 } 977 } 978 979 func CombineArray(others []*TransformerResult) *TransformerResult { 980 //exhaustruct:ignore 981 var r *TransformerResult = &TransformerResult{} 982 for i := range others { 983 r.Combine(others[i]) 984 } 985 return r 986 } 987 988 func (r *repository) ApplyTransformers(ctx context.Context, transformers ...Transformer) (*TransformerResult, *TransformerBatchApplyError) { 989 commitMsg, state, changes, applyErr := r.ApplyTransformersInternal(ctx, transformers...) 990 if applyErr != nil { 991 return nil, applyErr 992 } 993 if err := r.afterTransform(ctx, *state); err != nil { 994 return nil, &TransformerBatchApplyError{TransformerError: fmt.Errorf("%s: %w", "failure in afterTransform", err), Index: -1} 995 } 996 997 treeId, insertError := state.Filesystem.(*fs.TreeBuilderFS).Insert() 998 if insertError != nil { 999 return nil, &TransformerBatchApplyError{TransformerError: insertError, Index: -1} 1000 } 1001 committer := &git.Signature{ 1002 Name: r.config.CommitterName, 1003 Email: r.config.CommitterEmail, 1004 When: time.Now(), 1005 } 1006 1007 user, readUserErr := auth.ReadUserFromContext(ctx) 1008 1009 if readUserErr != nil { 1010 return nil, &TransformerBatchApplyError{ 1011 TransformerError: readUserErr, 1012 Index: -1, 1013 } 1014 } 1015 1016 author := &git.Signature{ 1017 Name: user.Name, 1018 Email: user.Email, 1019 When: time.Now(), 1020 } 1021 1022 var rev *git.Oid 1023 // the commit can be nil, if it's the first commit in the repo 1024 if state.Commit != nil { 1025 rev = state.Commit.Id() 1026 } 1027 oldCommitId := rev 1028 1029 newCommitId, createErr := r.repository.CreateCommitFromIds( 1030 fmt.Sprintf("refs/heads/%s", r.config.Branch), 1031 author, 1032 committer, 1033 strings.Join(commitMsg, "\n"), 1034 treeId, 1035 rev, 1036 ) 1037 if createErr != nil { 1038 return nil, &TransformerBatchApplyError{ 1039 TransformerError: fmt.Errorf("%s: %w", "createCommitFromIds failed", createErr), 1040 Index: -1, 1041 } 1042 } 1043 result := CombineArray(changes) 1044 result.Commits = &CommitIds{ 1045 Current: newCommitId, 1046 Previous: nil, 1047 } 1048 if oldCommitId != nil { 1049 result.Commits.Previous = oldCommitId 1050 } 1051 return result, nil 1052 } 1053 1054 func (r *repository) FetchAndReset(ctx context.Context) error { 1055 fetchSpec := fmt.Sprintf("+refs/heads/%s:refs/remotes/origin/%s", r.config.Branch, r.config.Branch) 1056 logger := logger.FromContext(ctx) 1057 //exhaustruct:ignore 1058 RemoteCallbacks := git.RemoteCallbacks{ 1059 UpdateTipsCallback: func(refname string, a *git.Oid, b *git.Oid) error { 1060 logger.Debug("git.fetched", 1061 zap.String("refname", refname), 1062 zap.String("revision.new", b.String()), 1063 ) 1064 return nil 1065 }, 1066 CredentialsCallback: r.credentials.CredentialsCallback(ctx), 1067 CertificateCheckCallback: r.certificates.CertificateCheckCallback(ctx), 1068 } 1069 fetchOptions := git.FetchOptions{ 1070 Prune: git.FetchPruneUnspecified, 1071 UpdateFetchhead: false, 1072 DownloadTags: git.DownloadTagsUnspecified, 1073 Headers: nil, 1074 ProxyOptions: git.ProxyOptions{ 1075 Type: git.ProxyTypeNone, 1076 Url: "", 1077 }, 1078 RemoteCallbacks: RemoteCallbacks, 1079 } 1080 err := r.useRemote(func(remote *git.Remote) error { 1081 return remote.Fetch([]string{fetchSpec}, &fetchOptions, "fetching") 1082 }) 1083 if err != nil { 1084 return err 1085 } 1086 var zero git.Oid 1087 var rev *git.Oid = &zero 1088 if remoteRef, err := r.repository.References.Lookup(fmt.Sprintf("refs/remotes/origin/%s", r.config.Branch)); err != nil { 1089 var gerr *git.GitError 1090 if errors.As(err, &gerr) && gerr.Code == git.ErrorCodeNotFound { 1091 // not found 1092 // nothing to do 1093 } else { 1094 return err 1095 } 1096 } else { 1097 rev = remoteRef.Target() 1098 if _, err := r.repository.References.Create(fmt.Sprintf("refs/heads/%s", r.config.Branch), rev, true, "reset branch"); err != nil { 1099 return err 1100 } 1101 } 1102 obj, err := r.repository.Lookup(rev) 1103 if err != nil { 1104 return err 1105 } 1106 commit, err := obj.AsCommit() 1107 if err != nil { 1108 return err 1109 } 1110 //exhaustruct:ignore 1111 err = r.repository.ResetToCommit(commit, git.ResetSoft, &git.CheckoutOptions{Strategy: git.CheckoutForce}) 1112 if err != nil { 1113 return err 1114 } 1115 return nil 1116 } 1117 1118 func (r *repository) Apply(ctx context.Context, transformers ...Transformer) error { 1119 defer func() { 1120 r.writesDone = r.writesDone + uint(len(transformers)) 1121 r.maybeGc(ctx) 1122 }() 1123 eCh := r.applyDeferred(ctx, transformers...) 1124 select { 1125 case err := <-eCh: 1126 return err 1127 case <-ctx.Done(): 1128 return ctx.Err() 1129 } 1130 } 1131 1132 func (r *repository) applyDeferred(ctx context.Context, transformers ...Transformer) <-chan error { 1133 return r.queue.add(ctx, transformers) 1134 } 1135 1136 // Push returns an 'error' for typing reasons, really it is always a git.GitError 1137 func (r *repository) Push(ctx context.Context, pushAction func() error) error { 1138 1139 span, ctx := tracer.StartSpanFromContext(ctx, "Apply") 1140 defer span.Finish() 1141 1142 eb := r.backOffProvider() 1143 return backoff.Retry( 1144 func() error { 1145 span, _ := tracer.StartSpanFromContext(ctx, "Push") 1146 defer span.Finish() 1147 err := pushAction() 1148 if err != nil { 1149 gerr, ok := err.(*git.GitError) 1150 if ok && gerr.Code == git.ErrorCodeNonFastForward { 1151 return backoff.Permanent(err) 1152 } 1153 } 1154 return err 1155 }, 1156 eb, 1157 ) 1158 } 1159 1160 func (r *repository) afterTransform(ctx context.Context, state State) error { 1161 span, ctx := tracer.StartSpanFromContext(ctx, "afterTransform") 1162 defer span.Finish() 1163 1164 configs, err := state.GetEnvironmentConfigs() 1165 if err != nil { 1166 return err 1167 } 1168 for env, config := range configs { 1169 if config.ArgoCd != nil { 1170 err := r.updateArgoCdApps(ctx, &state, env, config) 1171 if err != nil { 1172 return err 1173 } 1174 } 1175 } 1176 return nil 1177 } 1178 1179 func (r *repository) updateArgoCdApps(ctx context.Context, state *State, env string, config config.EnvironmentConfig) error { 1180 span, ctx := tracer.StartSpanFromContext(ctx, "updateArgoCdApps") 1181 defer span.Finish() 1182 fs := state.Filesystem 1183 if apps, err := state.GetEnvironmentApplications(env); err != nil { 1184 return err 1185 } else { 1186 spanCollectData, _ := tracer.StartSpanFromContext(ctx, "collectData") 1187 defer spanCollectData.Finish() 1188 appData := []argocd.AppData{} 1189 sort.Strings(apps) 1190 for _, appName := range apps { 1191 if err != nil { 1192 return err 1193 } 1194 team, err := state.GetApplicationTeamOwner(appName) 1195 if err != nil { 1196 return err 1197 } 1198 version, err := state.GetEnvironmentApplicationVersion(env, appName) 1199 if err != nil { 1200 if errors.Is(err, os.ErrNotExist) { 1201 // if the app does not exist, we skip it 1202 // (It may not exist at all, or just hasn't been deployed to this environment yet) 1203 continue 1204 } 1205 return err 1206 } 1207 if version == nil || *version == 0 { 1208 // if nothing is deployed, ignore it 1209 continue 1210 } 1211 appData = append(appData, argocd.AppData{ 1212 AppName: appName, 1213 TeamName: team, 1214 }) 1215 } 1216 spanCollectData.Finish() 1217 1218 spanRenderAndWrite, ctx := tracer.StartSpanFromContext(ctx, "RenderAndWrite") 1219 defer spanRenderAndWrite.Finish() 1220 if manifests, err := argocd.Render(ctx, r.config.URL, r.config.Branch, config, env, appData); err != nil { 1221 return err 1222 } else { 1223 spanWrite, _ := tracer.StartSpanFromContext(ctx, "Write") 1224 defer spanWrite.Finish() 1225 for apiVersion, content := range manifests { 1226 if err := fs.MkdirAll(fs.Join("argocd", string(apiVersion)), 0777); err != nil { 1227 return err 1228 } 1229 target := fs.Join("argocd", string(apiVersion), fmt.Sprintf("%s.yaml", env)) 1230 if err := util.WriteFile(fs, target, content, 0666); err != nil { 1231 return err 1232 } 1233 } 1234 } 1235 } 1236 return nil 1237 } 1238 1239 func (r *repository) State() *State { 1240 s, err := r.StateAt(nil) 1241 if err != nil { 1242 panic(err) 1243 } 1244 return s 1245 } 1246 1247 func (r *repository) StateAt(oid *git.Oid) (*State, error) { 1248 var commit *git.Commit 1249 if oid == nil { 1250 if obj, err := r.repository.RevparseSingle(fmt.Sprintf("refs/heads/%s", r.config.Branch)); err != nil { 1251 var gerr *git.GitError 1252 if errors.As(err, &gerr) { 1253 if gerr.Code == git.ErrorCodeNotFound { 1254 return &State{ 1255 Commit: nil, 1256 Filesystem: fs.NewEmptyTreeBuildFS(r.repository), 1257 BootstrapMode: r.config.BootstrapMode, 1258 EnvironmentConfigsPath: r.config.EnvironmentConfigsPath, 1259 }, nil 1260 } 1261 } 1262 return nil, err 1263 } else { 1264 commit, err = obj.AsCommit() 1265 if err != nil { 1266 return nil, err 1267 } 1268 } 1269 } else { 1270 var err error 1271 commit, err = r.repository.LookupCommit(oid) 1272 if err != nil { 1273 return nil, err 1274 } 1275 } 1276 return &State{ 1277 Filesystem: fs.NewTreeBuildFS(r.repository, commit.TreeId()), 1278 Commit: commit, 1279 BootstrapMode: r.config.BootstrapMode, 1280 EnvironmentConfigsPath: r.config.EnvironmentConfigsPath, 1281 }, nil 1282 } 1283 1284 func (r *repository) Notify() *notify.Notify { 1285 return &r.notify 1286 } 1287 1288 type ObjectCount struct { 1289 Count uint64 1290 Size uint64 1291 InPack uint64 1292 Packs uint64 1293 SizePack uint64 1294 Garbage uint64 1295 SizeGarbage uint64 1296 } 1297 1298 func (r *repository) countObjects(ctx context.Context) (ObjectCount, error) { 1299 var stats ObjectCount 1300 /* 1301 The output of `git count-objects` looks like this: 1302 count: 0 1303 size: 0 1304 in-pack: 635 1305 packs: 1 1306 size-pack: 2845 1307 prune-packable: 0 1308 garbage: 0 1309 size-garbage: 0 1310 */ 1311 cmd := exec.CommandContext(ctx, "git", "count-objects", "--verbose") 1312 cmd.Dir = r.config.Path 1313 out, err := cmd.Output() 1314 if err != nil { 1315 return stats, err 1316 } 1317 scanner := bufio.NewScanner(bytes.NewReader(out)) 1318 for scanner.Scan() { 1319 var ( 1320 token string 1321 value uint64 1322 ) 1323 if _, err := fmt.Sscan(scanner.Text(), &token, &value); err != nil { 1324 return stats, err 1325 } 1326 switch token { 1327 case "count:": 1328 stats.Count = value 1329 case "size:": 1330 stats.Size = value 1331 case "in-packs:": 1332 stats.InPack = value 1333 case "packs:": 1334 stats.Packs = value 1335 case "size-pack:": 1336 stats.SizePack = value 1337 case "garbage:": 1338 stats.Garbage = value 1339 case "size-garbage": 1340 stats.SizeGarbage = value 1341 } 1342 } 1343 return stats, nil 1344 } 1345 1346 func (r *repository) maybeGc(ctx context.Context) { 1347 if r.config.StorageBackend == SqliteBackend || r.config.GcFrequency == 0 || r.writesDone < r.config.GcFrequency { 1348 return 1349 } 1350 log := logger.FromContext(ctx) 1351 r.writesDone = 0 1352 timeBefore := time.Now() 1353 statsBefore, _ := r.countObjects(ctx) 1354 cmd := exec.CommandContext(ctx, "git", "repack", "-a", "-d") 1355 cmd.Dir = r.config.Path 1356 err := cmd.Run() 1357 if err != nil { 1358 log.Fatal("git.repack", zap.Error(err)) 1359 return 1360 } 1361 statsAfter, _ := r.countObjects(ctx) 1362 log.Info("git.repack", zap.Duration("duration", time.Since(timeBefore)), zap.Uint64("collected", statsBefore.Count-statsAfter.Count)) 1363 } 1364 1365 type State struct { 1366 Filesystem billy.Filesystem 1367 Commit *git.Commit 1368 BootstrapMode bool 1369 EnvironmentConfigsPath string 1370 } 1371 1372 func (s *State) Releases(application string) ([]uint64, error) { 1373 if entries, err := s.Filesystem.ReadDir(s.Filesystem.Join("applications", application, "releases")); err != nil { 1374 return nil, err 1375 } else { 1376 result := make([]uint64, 0, len(entries)) 1377 for _, e := range entries { 1378 if i, err := strconv.ParseUint(e.Name(), 10, 64); err != nil { 1379 // just skip 1380 } else { 1381 result = append(result, i) 1382 } 1383 } 1384 return result, nil 1385 } 1386 } 1387 1388 func (s *State) ReleaseManifests(application string, release uint64) (map[string]string, error) { 1389 base := s.Filesystem.Join("applications", application, "releases", strconv.FormatUint(release, 10), "environments") 1390 if entries, err := s.Filesystem.ReadDir(base); err != nil { 1391 return nil, err 1392 } else { 1393 result := make(map[string]string, len(entries)) 1394 for _, e := range entries { 1395 if buf, err := readFile(s.Filesystem, s.Filesystem.Join(base, e.Name(), "manifests.yaml")); err != nil { 1396 return nil, err 1397 } else { 1398 result[e.Name()] = string(buf) 1399 } 1400 } 1401 return result, nil 1402 } 1403 } 1404 1405 type Actor struct { 1406 Name string 1407 Email string 1408 } 1409 1410 type Lock struct { 1411 Message string 1412 CreatedBy Actor 1413 CreatedAt time.Time 1414 } 1415 1416 func readLock(fs billy.Filesystem, lockDir string) (*Lock, error) { 1417 lock := &Lock{ 1418 Message: "", 1419 CreatedBy: Actor{ 1420 Name: "", 1421 Email: "", 1422 }, 1423 CreatedAt: time.Time{}, 1424 } 1425 1426 if cnt, err := readFile(fs, fs.Join(lockDir, "message")); err != nil { 1427 if !os.IsNotExist(err) { 1428 return nil, err 1429 } 1430 } else { 1431 lock.Message = string(cnt) 1432 } 1433 1434 if cnt, err := readFile(fs, fs.Join(lockDir, "created_by_email")); err != nil { 1435 if !os.IsNotExist(err) { 1436 return nil, err 1437 } 1438 } else { 1439 lock.CreatedBy.Email = string(cnt) 1440 } 1441 1442 if cnt, err := readFile(fs, fs.Join(lockDir, "created_by_name")); err != nil { 1443 if !os.IsNotExist(err) { 1444 return nil, err 1445 } 1446 } else { 1447 lock.CreatedBy.Name = string(cnt) 1448 } 1449 1450 if cnt, err := readFile(fs, fs.Join(lockDir, "created_at")); err != nil { 1451 if !os.IsNotExist(err) { 1452 return nil, err 1453 } 1454 } else { 1455 if createdAt, err := time.Parse(time.RFC3339, strings.TrimSpace(string(cnt))); err != nil { 1456 return nil, err 1457 } else { 1458 lock.CreatedAt = createdAt 1459 } 1460 } 1461 1462 return lock, nil 1463 } 1464 1465 func (s *State) GetEnvLocksDir(environment string) string { 1466 return s.Filesystem.Join("environments", environment, "locks") 1467 } 1468 1469 func (s *State) GetEnvLockDir(environment string, lockId string) string { 1470 return s.Filesystem.Join(s.GetEnvLocksDir(environment), lockId) 1471 } 1472 1473 func (s *State) GetAppLocksDir(environment string, application string) string { 1474 return s.Filesystem.Join("environments", environment, "applications", application, "locks") 1475 } 1476 1477 func (s *State) GetEnvironmentLocks(environment string) (map[string]Lock, error) { 1478 base := s.GetEnvLocksDir(environment) 1479 if entries, err := s.Filesystem.ReadDir(base); err != nil { 1480 return nil, err 1481 } else { 1482 result := make(map[string]Lock, len(entries)) 1483 for _, e := range entries { 1484 if !e.IsDir() { 1485 return nil, fmt.Errorf("error getting environment locks: found file in the locks directory. run migration script to generate correct metadata") 1486 } 1487 if lock, err := readLock(s.Filesystem, s.Filesystem.Join(base, e.Name())); err != nil { 1488 return nil, err 1489 } else { 1490 result[e.Name()] = *lock 1491 } 1492 } 1493 return result, nil 1494 } 1495 } 1496 1497 func (s *State) GetEnvironmentApplicationLocks(environment, application string) (map[string]Lock, error) { 1498 base := s.GetAppLocksDir(environment, application) 1499 if entries, err := s.Filesystem.ReadDir(base); err != nil { 1500 return nil, err 1501 } else { 1502 result := make(map[string]Lock, len(entries)) 1503 for _, e := range entries { 1504 if !e.IsDir() { 1505 return nil, fmt.Errorf("error getting application locks: found file in the locks directory. run migration script to generate correct metadata") 1506 } 1507 if lock, err := readLock(s.Filesystem, s.Filesystem.Join(base, e.Name())); err != nil { 1508 return nil, err 1509 } else { 1510 result[e.Name()] = *lock 1511 } 1512 } 1513 return result, nil 1514 } 1515 } 1516 1517 func (s *State) GetDeploymentMetaData(environment, application string) (string, time.Time, error) { 1518 base := s.Filesystem.Join("environments", environment, "applications", application) 1519 author, err := readFile(s.Filesystem, s.Filesystem.Join(base, "deployed_by")) 1520 if err != nil { 1521 if os.IsNotExist(err) { 1522 // for backwards compatibility, we do not return an error here 1523 return "", time.Time{}, nil 1524 } else { 1525 return "", time.Time{}, err 1526 } 1527 } 1528 1529 time_utc, err := readFile(s.Filesystem, s.Filesystem.Join(base, "deployed_at_utc")) 1530 if err != nil { 1531 if os.IsNotExist(err) { 1532 return string(author), time.Time{}, nil 1533 } else { 1534 return "", time.Time{}, err 1535 } 1536 } 1537 1538 deployedAt, err := time.Parse("2006-01-02 15:04:05 -0700 MST", strings.TrimSpace(string(time_utc))) 1539 if err != nil { 1540 return "", time.Time{}, err 1541 } 1542 1543 return string(author), deployedAt, nil 1544 } 1545 1546 func (s *State) DeleteAppLockIfEmpty(ctx context.Context, environment string, application string) error { 1547 dir := s.GetAppLocksDir(environment, application) 1548 _, err := s.DeleteDirIfEmpty(dir) 1549 return err 1550 } 1551 1552 func (s *State) DeleteEnvLockIfEmpty(ctx context.Context, environment string) error { 1553 dir := s.GetEnvLocksDir(environment) 1554 _, err := s.DeleteDirIfEmpty(dir) 1555 return err 1556 } 1557 1558 type SuccessReason int64 1559 1560 const ( 1561 NoReason SuccessReason = iota 1562 DirDoesNotExist 1563 DirNotEmpty 1564 ) 1565 1566 // DeleteDirIfEmpty if it's empty. If the dir does not exist or is not empty, nothing happens. 1567 // Errors are only returned if the read or delete operations fail. 1568 // Returns SuccessReason for unit testing. 1569 func (s *State) DeleteDirIfEmpty(directoryName string) (SuccessReason, error) { 1570 fileInfos, err := s.Filesystem.ReadDir(directoryName) 1571 if err != nil { 1572 return NoReason, fmt.Errorf("DeleteDirIfEmpty: failed to read directory %q: %w", directoryName, err) 1573 } 1574 if fileInfos == nil { 1575 // directory does not exist, nothing to do 1576 return DirDoesNotExist, nil 1577 } 1578 if len(fileInfos) == 0 { 1579 // directory exists, and is empty: delete it 1580 err = s.Filesystem.Remove(directoryName) 1581 if err != nil { 1582 return NoReason, fmt.Errorf("DeleteDirIfEmpty: failed to delete directory %q: %w", directoryName, err) 1583 } 1584 return NoReason, nil 1585 } 1586 return DirNotEmpty, nil 1587 } 1588 1589 func (s *State) GetQueuedVersion(environment string, application string) (*uint64, error) { 1590 return s.readSymlink(environment, application, queueFileName) 1591 } 1592 1593 func (s *State) DeleteQueuedVersion(environment string, application string) error { 1594 queuedVersion := s.Filesystem.Join("environments", environment, "applications", application, queueFileName) 1595 return s.Filesystem.Remove(queuedVersion) 1596 } 1597 1598 func (s *State) DeleteQueuedVersionIfExists(environment string, application string) error { 1599 queuedVersion, err := s.GetQueuedVersion(environment, application) 1600 if err != nil { 1601 return err 1602 } 1603 if queuedVersion == nil { 1604 return nil // nothing to do 1605 } 1606 return s.DeleteQueuedVersion(environment, application) 1607 } 1608 1609 func (s *State) GetEnvironmentApplicationVersion(environment, application string) (*uint64, error) { 1610 return s.readSymlink(environment, application, "version") 1611 } 1612 1613 // returns nil if there is no file 1614 func (s *State) readSymlink(environment string, application string, symlinkName string) (*uint64, error) { 1615 version := s.Filesystem.Join("environments", environment, "applications", application, symlinkName) 1616 if lnk, err := s.Filesystem.Readlink(version); err != nil { 1617 if errors.Is(err, os.ErrNotExist) { 1618 // if the link does not exist, we return nil 1619 return nil, nil 1620 } 1621 return nil, fmt.Errorf("failed reading symlink %q: %w", version, err) 1622 } else { 1623 target := s.Filesystem.Join("environments", environment, "applications", application, lnk) 1624 if stat, err := s.Filesystem.Stat(target); err != nil { 1625 // if the file that the link points to does not exist, that's an error 1626 return nil, fmt.Errorf("failed stating %q: %w", target, err) 1627 } else { 1628 res, err := strconv.ParseUint(stat.Name(), 10, 64) 1629 return &res, err 1630 } 1631 } 1632 } 1633 1634 var InvalidJson = errors.New("JSON file is not valid") 1635 1636 func envExists(envConfigs map[string]config.EnvironmentConfig, envNameToSearchFor string) bool { 1637 if _, found := envConfigs[envNameToSearchFor]; found { 1638 return true 1639 } 1640 return false 1641 } 1642 1643 func (s *State) GetEnvironmentConfigsAndValidate(ctx context.Context) (map[string]config.EnvironmentConfig, error) { 1644 logger := logger.FromContext(ctx) 1645 envConfigs, err := s.GetEnvironmentConfigs() 1646 if err != nil { 1647 return nil, err 1648 } 1649 if len(envConfigs) == 0 { 1650 logger.Warn("No environment configurations found. Check git settings like the branch name. Kuberpult cannot operate without environments.") 1651 } 1652 for envName, env := range envConfigs { 1653 if env.Upstream == nil || env.Upstream.Environment == "" { 1654 continue 1655 } 1656 upstreamEnv := env.Upstream.Environment 1657 if !envExists(envConfigs, upstreamEnv) { 1658 logger.Warn(fmt.Sprintf("The environment '%s' has upstream '%s' configured, but the environment '%s' does not exist.", envName, upstreamEnv, upstreamEnv)) 1659 } 1660 } 1661 envGroups := mapper.MapEnvironmentsToGroups(envConfigs) 1662 for _, group := range envGroups { 1663 grpDist := group.Environments[0].DistanceToUpstream 1664 for _, env := range group.Environments { 1665 if env.DistanceToUpstream != grpDist { 1666 logger.Warn(fmt.Sprintf("The environment group '%s' has multiple environments setup with different distances to upstream", group.EnvironmentGroupName)) 1667 } 1668 } 1669 } 1670 return envConfigs, err 1671 } 1672 1673 func (s *State) GetEnvironmentConfigs() (map[string]config.EnvironmentConfig, error) { 1674 if s.BootstrapMode { 1675 result := map[string]config.EnvironmentConfig{} 1676 buf, err := os.ReadFile(s.EnvironmentConfigsPath) 1677 if err != nil { 1678 if errors.Is(err, os.ErrNotExist) { 1679 return result, nil 1680 } 1681 return nil, err 1682 } 1683 err = json.Unmarshal(buf, &result) 1684 if err != nil { 1685 return nil, err 1686 } 1687 return result, nil 1688 } else { 1689 envs, err := s.Filesystem.ReadDir("environments") 1690 if err != nil { 1691 return nil, err 1692 } 1693 result := map[string]config.EnvironmentConfig{} 1694 for _, env := range envs { 1695 c, err := s.GetEnvironmentConfig(env.Name()) 1696 if err != nil { 1697 return nil, err 1698 1699 } 1700 result[env.Name()] = *c 1701 } 1702 return result, nil 1703 } 1704 } 1705 1706 func (s *State) GetEnvironmentConfig(environmentName string) (*config.EnvironmentConfig, error) { 1707 fileName := s.Filesystem.Join("environments", environmentName, "config.json") 1708 var config config.EnvironmentConfig 1709 if err := decodeJsonFile(s.Filesystem, fileName, &config); err != nil { 1710 if !errors.Is(err, os.ErrNotExist) { 1711 return nil, fmt.Errorf("%s : %w", fileName, InvalidJson) 1712 } 1713 } 1714 return &config, nil 1715 } 1716 1717 func (s *State) GetEnvironmentConfigsForGroup(envGroup string) ([]string, error) { 1718 allEnvConfigs, err := s.GetEnvironmentConfigs() 1719 if err != nil { 1720 return nil, err 1721 } 1722 groupEnvNames := []string{} 1723 for env := range allEnvConfigs { 1724 envConfig := allEnvConfigs[env] 1725 g := envConfig.EnvironmentGroup 1726 if g != nil && *g == envGroup { 1727 groupEnvNames = append(groupEnvNames, env) 1728 } 1729 } 1730 if len(groupEnvNames) == 0 { 1731 return nil, fmt.Errorf("No environment found with given group '%s'", envGroup) 1732 } 1733 sort.Strings(groupEnvNames) 1734 return groupEnvNames, nil 1735 } 1736 1737 func (s *State) GetEnvironmentApplications(environment string) ([]string, error) { 1738 appDir := s.Filesystem.Join("environments", environment, "applications") 1739 return names(s.Filesystem, appDir) 1740 } 1741 1742 func (s *State) GetApplications() ([]string, error) { 1743 return names(s.Filesystem, "applications") 1744 } 1745 1746 func (s *State) GetApplicationReleases(application string) ([]uint64, error) { 1747 if ns, err := names(s.Filesystem, s.Filesystem.Join("applications", application, "releases")); err != nil { 1748 return nil, err 1749 } else { 1750 result := make([]uint64, 0, len(ns)) 1751 for _, n := range ns { 1752 if i, err := strconv.ParseUint(n, 10, 64); err == nil { 1753 result = append(result, i) 1754 } 1755 } 1756 sort.Slice(result, func(i, j int) bool { 1757 return result[i] < result[j] 1758 }) 1759 return result, nil 1760 } 1761 } 1762 1763 type Release struct { 1764 Version uint64 1765 /** 1766 "UndeployVersion=true" means that this version is empty, and has no manifest that could be deployed. 1767 It is intended to help cleanup old services within the normal release cycle (e.g. dev->staging->production). 1768 */ 1769 UndeployVersion bool 1770 SourceAuthor string 1771 SourceCommitId string 1772 SourceMessage string 1773 CreatedAt time.Time 1774 DisplayVersion string 1775 } 1776 1777 func (rel *Release) ToProto() *api.Release { 1778 if rel == nil { 1779 return nil 1780 } 1781 return &api.Release{ 1782 PrNumber: extractPrNumber(rel.SourceMessage), 1783 Version: rel.Version, 1784 SourceAuthor: rel.SourceAuthor, 1785 SourceCommitId: rel.SourceCommitId, 1786 SourceMessage: rel.SourceMessage, 1787 UndeployVersion: rel.UndeployVersion, 1788 CreatedAt: timestamppb.New(rel.CreatedAt), 1789 DisplayVersion: rel.DisplayVersion, 1790 } 1791 } 1792 1793 func extractPrNumber(sourceMessage string) string { 1794 re := regexp.MustCompile(`\(#(\d+)\)`) 1795 res := re.FindAllStringSubmatch(sourceMessage, -1) 1796 1797 if len(res) == 0 { 1798 return "" 1799 } else { 1800 return res[len(res)-1][1] 1801 } 1802 } 1803 1804 func (s *State) IsUndeployVersion(application string, version uint64) (bool, error) { 1805 base := releasesDirectoryWithVersion(s.Filesystem, application, version) 1806 _, err := s.Filesystem.Stat(base) 1807 if err != nil { 1808 return false, wrapFileError(err, base, "could not call stat") 1809 } 1810 if _, err := readFile(s.Filesystem, s.Filesystem.Join(base, "undeploy")); err != nil { 1811 if !os.IsNotExist(err) { 1812 return false, err 1813 } 1814 return false, nil 1815 } 1816 return true, nil 1817 } 1818 1819 func (s *State) GetApplicationRelease(application string, version uint64) (*Release, error) { 1820 base := releasesDirectoryWithVersion(s.Filesystem, application, version) 1821 _, err := s.Filesystem.Stat(base) 1822 if err != nil { 1823 return nil, wrapFileError(err, base, "could not call stat") 1824 } 1825 release := Release{ 1826 Version: version, 1827 UndeployVersion: false, 1828 SourceAuthor: "", 1829 SourceCommitId: "", 1830 SourceMessage: "", 1831 CreatedAt: time.Time{}, 1832 DisplayVersion: "", 1833 } 1834 if cnt, err := readFile(s.Filesystem, s.Filesystem.Join(base, "source_commit_id")); err != nil { 1835 if !os.IsNotExist(err) { 1836 return nil, err 1837 } 1838 } else { 1839 release.SourceCommitId = string(cnt) 1840 } 1841 if cnt, err := readFile(s.Filesystem, s.Filesystem.Join(base, "source_author")); err != nil { 1842 if !os.IsNotExist(err) { 1843 return nil, err 1844 } 1845 } else { 1846 release.SourceAuthor = string(cnt) 1847 } 1848 if cnt, err := readFile(s.Filesystem, s.Filesystem.Join(base, "source_message")); err != nil { 1849 if !os.IsNotExist(err) { 1850 return nil, err 1851 } 1852 } else { 1853 release.SourceMessage = string(cnt) 1854 } 1855 if displayVersion, err := readFile(s.Filesystem, s.Filesystem.Join(base, "display_version")); err != nil { 1856 if !os.IsNotExist(err) { 1857 return nil, err 1858 } 1859 release.DisplayVersion = "" 1860 } else { 1861 release.DisplayVersion = string(displayVersion) 1862 } 1863 isUndeploy, err := s.IsUndeployVersion(application, version) 1864 if err != nil { 1865 return nil, err 1866 } 1867 release.UndeployVersion = isUndeploy 1868 if cnt, err := readFile(s.Filesystem, s.Filesystem.Join(base, "created_at")); err != nil { 1869 if !os.IsNotExist(err) { 1870 return nil, err 1871 } 1872 } else { 1873 if releaseTime, err := time.Parse(time.RFC3339, strings.TrimSpace(string(cnt))); err != nil { 1874 return nil, err 1875 } else { 1876 release.CreatedAt = releaseTime 1877 } 1878 } 1879 return &release, nil 1880 } 1881 1882 func (s *State) GetApplicationReleaseManifests(application string, version uint64) (map[string]*api.Manifest, error) { 1883 dir := manifestDirectoryWithReleasesVersion(s.Filesystem, application, version) 1884 1885 entries, err := s.Filesystem.ReadDir(dir) 1886 if err != nil { 1887 return nil, fmt.Errorf("reading manifest directory: %w", err) 1888 } 1889 manifests := map[string]*api.Manifest{} 1890 for _, entry := range entries { 1891 if !entry.IsDir() { 1892 continue 1893 } 1894 manifestPath := filepath.Join(dir, entry.Name(), "manifests.yaml") 1895 file, err := s.Filesystem.Open(manifestPath) 1896 if err != nil { 1897 return nil, fmt.Errorf("failed to open %s: %w", manifestPath, err) 1898 } 1899 content, err := io.ReadAll(file) 1900 if err != nil { 1901 return nil, fmt.Errorf("failed to read %s: %w", manifestPath, err) 1902 } 1903 1904 manifests[entry.Name()] = &api.Manifest{ 1905 Environment: entry.Name(), 1906 Content: string(content), 1907 } 1908 } 1909 return manifests, nil 1910 } 1911 1912 func (s *State) GetApplicationTeamOwner(application string) (string, error) { 1913 appDir := applicationDirectory(s.Filesystem, application) 1914 appTeam := s.Filesystem.Join(appDir, "team") 1915 1916 if team, err := readFile(s.Filesystem, appTeam); err != nil { 1917 if os.IsNotExist(err) { 1918 return "", nil 1919 } else { 1920 return "", fmt.Errorf("error while reading team owner file for application %v found: %w", application, err) 1921 } 1922 } else { 1923 return string(team), nil 1924 } 1925 } 1926 1927 func (s *State) GetApplicationSourceRepoUrl(application string) (string, error) { 1928 appDir := applicationDirectory(s.Filesystem, application) 1929 appSourceRepoUrl := s.Filesystem.Join(appDir, "sourceRepoUrl") 1930 1931 if url, err := readFile(s.Filesystem, appSourceRepoUrl); err != nil { 1932 if os.IsNotExist(err) { 1933 return "", nil 1934 } else { 1935 return "", fmt.Errorf("error while reading sourceRepoUrl file for application %v found: %w", application, err) 1936 } 1937 } else { 1938 return string(url), nil 1939 } 1940 } 1941 1942 func names(fs billy.Filesystem, path string) ([]string, error) { 1943 files, err := fs.ReadDir(path) 1944 if err != nil { 1945 return nil, err 1946 } 1947 result := make([]string, 0, len(files)) 1948 for _, app := range files { 1949 result = append(result, app.Name()) 1950 } 1951 return result, nil 1952 } 1953 1954 func decodeJsonFile(fs billy.Filesystem, path string, out interface{}) error { 1955 if file, err := fs.Open(path); err != nil { 1956 return wrapFileError(err, path, "could not decode json file") 1957 } else { 1958 defer file.Close() 1959 dec := json.NewDecoder(file) 1960 return dec.Decode(out) 1961 } 1962 } 1963 1964 func readFile(fs billy.Filesystem, path string) ([]byte, error) { 1965 if file, err := fs.Open(path); err != nil { 1966 return nil, err 1967 } else { 1968 defer file.Close() 1969 return io.ReadAll(file) 1970 } 1971 } 1972 1973 // ProcessQueue checks if there is something in the queue 1974 // deploys if necessary 1975 // deletes the queue 1976 func (s *State) ProcessQueue(ctx context.Context, fs billy.Filesystem, environment string, application string) (string, error) { 1977 queuedVersion, err := s.GetQueuedVersion(environment, application) 1978 queueDeploymentMessage := "" 1979 if err != nil { 1980 // could not read queued version. 1981 return "", err 1982 } else { 1983 if queuedVersion == nil { 1984 // if there is no version queued, that's not an issue, just do nothing: 1985 return "", nil 1986 } 1987 1988 currentlyDeployedVersion, err := s.GetEnvironmentApplicationVersion(environment, application) 1989 if err != nil { 1990 return "", err 1991 } 1992 1993 if currentlyDeployedVersion != nil && *queuedVersion == *currentlyDeployedVersion { 1994 // delete queue, it's outdated! But if we can't, that's not really a problem, as it would be overwritten 1995 // whenever the next deployment happens: 1996 err = s.DeleteQueuedVersion(environment, application) 1997 return fmt.Sprintf("deleted queued version %d because it was already deployed. app=%q env=%q", *queuedVersion, application, environment), err 1998 } 1999 } 2000 return queueDeploymentMessage, nil 2001 }