Repository: ovh/cds
Pull request #5468 (2020-10-01 15:30:01)

fix(cdn): adding log
1. Description
1. Related issues
1. About tests
1. Mentions

@ovh/cds


sonarcloud[bot]: Kudos, SonarCloud Quality Gate passed!

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/bug.png' alt='Bug' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=BUG) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=BUG) [0 Bugs](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=BUG)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/vulnerability.png' alt='Vulnerability' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=VULNERABILITY) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=VULNERABILITY) [0 Vulnerabilities](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=VULNERABILITY) (and [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/security_hotspot.png' alt='Security Hotspot' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=SECURITY_HOTSPOT) [0 Security Hotspots](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=SECURITY_HOTSPOT) to review)  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/code_smell.png' alt='Code Smell' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=CODE_SMELL) [<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/RatingBadge/A.png' alt='A' width='16' height='16' />](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=CODE_SMELL) [0 Code Smells](https://sonarcloud.io/project/issues?id=ovh_cds&pullRequest=5468&resolved=false&types=CODE_SMELL)

[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/CoverageChart/NoCoverageInfo.png' alt='No Coverage information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=ovh_cds&pullRequest=5468&metric=coverage&view=list) No Coverage information  
[<img src='https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/Duplications/NoDuplicationInfo.png' alt='No Duplication information' width='16' height='16' />](https://sonarcloud.io/component_measures?id=ovh_cds&pullRequest=5468&metric=duplicated_lines_density&view=list) No Duplication information


ovh-cds: 
CDS Report build-all-cds#14552.0 ✘  
* Build
  * Build and Package All ✔ 
  * Build Debpacker ✔ 
  * Build OS-Ansible-Inventory ✔ 
  * Build SMTPMock ✔ 
  * Docs -   
  * Lint UI Translate ✔ 
  * Test CLI ✔ 
  * Test Engine ✔ 
  * Test SDK ✔ 
  * Test SDK Rust ✔ 
  * Test UI ✔ 
* Package and Analyze
  * Docker ✔ 
  * Sonar ✔ 
* Integration
  * Docker Compose Tests ✔ 
  * Standalone Tests ✘  
  * Standalone tests 1 -   
  * Standalone tests 2 -   
  * Standalone tests 3 -   


Patch:
diff --git a/docs/content/docs/concepts/variables.md b/docs/content/docs/concepts/variables.md
index 18059be49..49ed6709a 100644
--- a/docs/content/docs/concepts/variables.md
+++ b/docs/content/docs/concepts/variables.md
@@ -46,7 +46,7 @@ Here is the list of builtin variables, generated for every build:
 - `{{.cds.job}}` The name of the current job
 - `{{.cds.manual}}` true if current pipeline is manually run, false otherwise
 - `{{.cds.pipeline}}` The name of the current pipeline
-- `{{.cds.project}}` The name of the current project
+- `{{.cds.project}}` The key of the current project
 - `{{.cds.run}}` Run Number of current workflow, example: 3.0
 - `{{.cds.run.number}}` Number of current workflow, example: 3 if `{{.cds.run}} = 3.0`
 - `{{.cds.run.subnumber}}` Sub Number of current workflow, example: 4 if `{{.cds.run}} = 3.4`
diff --git a/engine/Makefile b/engine/Makefile
index 01a634581..0b57d275b 100644
--- a/engine/Makefile
+++ b/engine/Makefile
@@ -45,6 +45,8 @@ TEST_REDIS_HOST = $(if ${CDS_CACHE_REDIS_HOST},${CDS_CACHE_REDIS_HOST},localhost
 TEST_REDIS_PASS = $(if ${CDS_CACHE_REDIS_PASS},${CDS_CACHE_REDIS_PASS},)
 TEST_REDIS_START_DOCKER = docker run -d -p $(TEST_REDIS_PORT):6379 --name redis-cds redis
 
+TEST_KAFKA_START_DOCKER = docker run -d -p 2181:2181 -p 9092:9092 --env ADVERTISED_HOST=`hostname` --env ADVERTISED_PORT=9092 --name kafka-cds spotify/kafka
+
 TEST_CMD = go test -tags skipkeychaintests -v -timeout 600s -coverprofile=profile.coverprofile
 
 $(TARGET_DIR):
@@ -104,6 +106,9 @@ build_all_engine: $(TARGET_ENGINE_BINARIES)
 test-redis-start-docker:
 	$(TEST_REDIS_START_DOCKER)
 
+test-kafka-start-docker:
+	$(TEST_KAFKA_START_DOCKER)
+
 test-db-stop-docker:
 	$(TEST_DB_STOP_DOCKER)
 
diff --git a/engine/api/mail/mail.go b/engine/api/mail/mail.go
index 69650c59d..26a774e24 100644
--- a/engine/api/mail/mail.go
+++ b/engine/api/mail/mail.go
@@ -90,28 +90,30 @@ func smtpClient(ctx context.Context) (*smtp.Client, error) {
 		// Here is the key, you need to call tls.Dial instead of smtp.Dial
 		// for smtp servers running on 465 that require an ssl connection
 		// from the very beginning (no starttls)
-		conn, errc := tls.Dial("tcp", servername, tlsconfig)
-		if errc != nil {
-			log.Warning(ctx, "Error with c.Dial:%s\n", errc.Error())
-			return nil, errc
+		conn, err := tls.Dial("tcp", servername, tlsconfig)
+		if err != nil {
+			log.Warning(ctx, "Error with c.Dial:%s\n", err.Error())
+			return nil, sdk.WithStack(err)
 		}
 
 		c, err = smtp.NewClient(conn, smtpHost)
 		if err != nil {
 			log.Warning(ctx, "Error with c.NewClient:%s\n", err.Error())
-			return nil, err
+			return nil, sdk.WithStack(err)
 		}
 		// TLS config
 		tlsconfig := &tls.Config{
 			InsecureSkipVerify: false,
 			ServerName:         smtpHost,
 		}
-		c.StartTLS(tlsconfig)
+		if err := c.StartTLS(tlsconfig); err != nil {
+			return nil, sdk.WithStack(err)
+		}
 	} else {
 		c, err = smtp.Dial(servername)
 		if err != nil {
 			log.Warning(ctx, "Error with c.NewClient:%s\n", err.Error())
-			return nil, err
+			return nil, sdk.WithStack(err)
 		}
 	}
 
@@ -183,7 +185,12 @@ func SendEmail(ctx context.Context, subject string, mailContent *bytes.Buffer, u
 	headers := make(map[string]string)
 	headers["From"] = smtpFrom
 	headers["To"] = to.String()
-	headers["Subject"] = subject
+	if sdk.StringIsAscii(subject) {
+		headers["Subject"] = subject
+	} else {
+		// https://tools.ietf.org/html/rfc2047
+		headers["Subject"] = "=?UTF-8?Q?" + subject + "?="
+	}
 
 	// https://tools.ietf.org/html/rfc4021
 	headers["Date"] = time.Now().Format(time.RFC1123Z)
diff --git a/engine/api/repositories_manager.go b/engine/api/repositories_manager.go
index f54242e9a..614f6597b 100644
--- a/engine/api/repositories_manager.go
+++ b/engine/api/repositories_manager.go
@@ -14,7 +14,6 @@ import (
 	"github.com/ovh/cds/engine/api/event"
 	"github.com/ovh/cds/engine/api/project"
 	"github.com/ovh/cds/engine/api/repositoriesmanager"
-	"github.com/ovh/cds/engine/api/services"
 	"github.com/ovh/cds/engine/api/workflow"
 	"github.com/ovh/cds/engine/cache"
 	"github.com/ovh/cds/engine/service"
@@ -135,19 +134,11 @@ func (api *API) repositoriesManagerOAuthCallbackHandler() service.Handler {
 		consumerID := data["consumer_id"]
 
 		authConsumer, err := authentication.LoadConsumerByID(ctx, api.mustDB(), consumerID,
-			authentication.LoadConsumerOptions.WithAuthentifiedUser,
-			authentication.LoadConsumerOptions.WithConsumerGroups)
+			authentication.LoadConsumerOptions.WithAuthentifiedUser)
 		if err != nil {
 			return sdk.WrapError(sdk.ErrForbidden, "repositoriesManagerAuthorizeCallback> Error")
 		}
 
-		// Add service for consumer if exists
-		s, err := services.LoadByConsumerID(context.Background(), api.mustDB(), authConsumer.ID)
-		if err != nil && !sdk.ErrorIs(err, sdk.ErrNotFound) {
-			return sdk.WrapError(sdk.ErrForbidden, "repositoriesManagerAuthorizeCallback> Error")
-		}
-		authConsumer.Service = s
-
 		proj, errP := project.Load(ctx, api.mustDB(), projectKey)
 		if errP != nil {
 			return sdk.WrapError(errP, "repositoriesManagerAuthorizeCallback> Cannot load project")
diff --git a/engine/api/suggest.go b/engine/api/suggest.go
index b85a0f25f..8323e6e9e 100644
--- a/engine/api/suggest.go
+++ b/engine/api/suggest.go
@@ -8,8 +8,6 @@ import (
 
 	"github.com/gorilla/mux"
 
-	"github.com/ovh/cds/engine/api/application"
-	"github.com/ovh/cds/engine/api/environment"
 	"github.com/ovh/cds/engine/api/pipeline"
 	"github.com/ovh/cds/engine/api/project"
 	"github.com/ovh/cds/engine/service"
@@ -20,7 +18,6 @@ func (api *API) getVariablesHandler() service.Handler {
 	return func(ctx context.Context, w http.ResponseWriter, r *http.Request) error {
 		vars := mux.Vars(r)
 		projectKey := vars[permProjectKey]
-		appName := r.FormValue("appName")
 		pipID := r.FormValue("pipId")
 
 		var allVariables []string
@@ -35,42 +32,10 @@ func (api *API) getVariablesHandler() service.Handler {
 		}
 		allVariables = append(allVariables, projectVar...)
 
-		env, err := environment.LoadEnvironmentByName(api.mustDB(), projectKey, appName)
-		if err != nil {
-			return err
-		}
-
-		envVars, err := environment.LoadAllVariables(api.mustDB(), env.ID)
-		if err != nil {
-			return err
-		}
-
-		envVarNameArray := make([]string, len(envVars))
-		for i := range envVars {
-			envVarNameArray[i] = envVars[i].Name
-		}
-
-		for i := range envVarNameArray {
-			envVarNameArray[i] = fmt.Sprintf("{{.cds.env.%s}}", envVarNameArray[i])
-		}
-		allVariables = append(allVariables, envVarNameArray...)
-
-		// Load app
 		appVar := []string{}
-		if appName != "" {
-			// Check permission on application
-			app, err := application.LoadByName(api.mustDB(), projectKey, appName, application.LoadOptions.WithVariables)
-			if err != nil {
-				return sdk.WrapError(err, "Cannot Load application")
-			}
-
-			for _, v := range app.Variables {
-				appVar = append(appVar, fmt.Sprintf("{{.cds.app.%s}}", v.Name))
-			}
 
-		} else {
-			// Load all app variables
-			query := `
+		// Load all app variables
+		query := `
 			SELECT distinct var_name
 			FROM application_variable
 			LEFT JOIN application ON application.id = application_variable.application_id
@@ -78,20 +43,18 @@ func (api *API) getVariablesHandler() service.Handler {
 			WHERE project.projectkey = $1
 			ORDER BY var_name;
 		`
-			rows, err := api.mustDB().Query(query, projectKey)
+		rows, err := api.mustDB().Query(query, projectKey)
+		if err != nil {
+			return sdk.WrapError(err, "Cannot Load all applications variables")
+		}
+		defer rows.Close()
+		for rows.Next() {
+			var name string
+			err := rows.Scan(&name)
 			if err != nil {
-				return sdk.WrapError(err, "Cannot Load all applications variables")
-			}
-			defer rows.Close()
-			for rows.Next() {
-				var name string
-				err := rows.Scan(&name)
-				if err != nil {
-					return sdk.WrapError(err, "Cannot scan results")
-				}
-				appVar = append(appVar, fmt.Sprintf("{{.cds.app.%s}}", name))
-
+				return sdk.WrapError(err, "Cannot scan results")
 			}
+			appVar = append(appVar, fmt.Sprintf("{{.cds.app.%s}}", name))
 		}
 		allVariables = append(allVariables, appVar...)
 
diff --git a/engine/api/worker/init.go b/engine/api/worker/init.go
index a2008a59e..c74dc9451 100644
--- a/engine/api/worker/init.go
+++ b/engine/api/worker/init.go
@@ -19,7 +19,7 @@ func Initialize(c context.Context, DBFunc func() *gorp.DbMap, store cache.Store)
 		select {
 		case <-c.Done():
 			if c.Err() != nil {
-				log.Error(c, "Exiting workflow ticker: %v", c.Err())
+				log.Error(c, "Exiting worker ticker: %v", c.Err())
 				return nil
 			}
 		case <-tickHeart.C:
@@ -31,7 +31,7 @@ func Initialize(c context.Context, DBFunc func() *gorp.DbMap, store cache.Store)
 
 			go func() {
 				if err := DisableDeadWorkers(c, db); err != nil {
-					log.Warning(c, "workflow.disableDeadWorkers> Error on disableDeadWorkers : %v", err)
+					log.Warning(c, "worker.disableDeadWorkers> Error on disableDeadWorkers : %v", err)
 				}
 			}()
 		}
diff --git a/engine/cdn/cdn_log.go b/engine/cdn/cdn_log.go
index 8e24540a5..225e75b37 100644
--- a/engine/cdn/cdn_log.go
+++ b/engine/cdn/cdn_log.go
@@ -24,7 +24,7 @@ import (
 )
 
 var (
-	logCache                   = gocache.New(20*time.Minute, 30*time.Minute)
+	logCache                   = gocache.New(20*time.Minute, 20*time.Minute)
 	keyJobLogIncomingQueue     = cache.Key("cdn", "log", "incoming", "job")
 	keyServiceLogIncomingQueue = cache.Key("cdn", "log", "incoming", "service")
 )
@@ -155,7 +155,7 @@ func (s *Service) handleWorkerLog(ctx context.Context, workerName string, worker
 
 	// Verify Signature
 	if err := jws.Verify(workerData.PrivateKey, sig.(string), &signature); err != nil {
-		return err
+		return sdk.WrapError(err, "worker key: %d", len(workerData.PrivateKey))
 	}
 	if workerData.JobRunID == nil || *workerData.JobRunID != signature.JobID || workerData.ID != workerID {
 		return sdk.WithStack(sdk.ErrForbidden)
diff --git a/engine/cmd_start.go b/engine/cmd_start.go
index a2e7cfae3..e02d9fad7 100644
--- a/engine/cmd_start.go
+++ b/engine/cmd_start.go
@@ -50,6 +50,12 @@ var (
 	flagStartVaultToken      string
 )
 
+type serviceConf struct {
+	arg     string
+	service service.Service
+	cfg     interface{}
+}
+
 var startCmd = &cobra.Command{
 	Use:   "start",
 	Short: "Start CDS",
@@ -113,21 +119,6 @@ See $ engine config command for more details.
 		// initialize context
 		defer cancel()
 
-		// gracefully shutdown all
-		c := make(chan os.Signal, 1)
-		signal.Notify(c, os.Interrupt, syscall.SIGTERM)
-		go func() {
-			<-c
-			signal.Stop(c)
-			cancel()
-		}()
-
-		type serviceConf struct {
-			arg     string
-			service service.Service
-			cfg     interface{}
-		}
-
 		var (
 			serviceConfs []serviceConf
 			names        []string
@@ -255,6 +246,16 @@ See $ engine config command for more details.
 			}
 		}
 
+		// gracefully shutdown all
+		c := make(chan os.Signal, 1)
+		signal.Notify(c, os.Interrupt, syscall.SIGTERM)
+		go func(ctx context.Context) {
+			<-c
+			unregisterServices(ctx, serviceConfs)
+			signal.Stop(c)
+			cancel()
+		}(ctx)
+
 		//Initialize logs
 		logConf := log.Conf{
 			Level:                      conf.Log.Level,
@@ -313,12 +314,25 @@ See $ engine config command for more details.
 
 		//Wait for the end
 		<-ctx.Done()
-		if ctx.Err() != nil {
-			fmt.Printf("Exiting (%v)\n", ctx.Err())
-		}
+
 	},
 }
 
+func unregisterServices(ctx context.Context, serviceConfs []serviceConf) {
+	// unregister all services
+	for i := range serviceConfs {
+		s := serviceConfs[i]
+		fmt.Printf("Unregister (%v)\n", s.service.Name())
+		if err := s.service.Unregister(ctx); err != nil {
+			log.Error(ctx, "%s> Unable to unregister: %v", s.service.Name(), err)
+		}
+	}
+
+	if ctx.Err() != nil {
+		fmt.Printf("Exiting (%v)\n", ctx.Err())
+	}
+}
+
 func start(c context.Context, s service.Service, cfg interface{}, serviceName string) {
 	if err := serve(c, s, serviceName, cfg); err != nil {
 		fmt.Printf("Service has been stopped: %s %+v", serviceName, err)
diff --git a/engine/service/service.go b/engine/service/service.go
index 0386c40db..cc124cb6d 100644
--- a/engine/service/service.go
+++ b/engine/service/service.go
@@ -104,6 +104,7 @@ loop:
 	return nil
 }
 
+// Register registers a new service on API
 func (c *Common) Register(ctx context.Context, cfg sdk.ServiceConfig) error {
 	log.Info(ctx, "Registing service %s(%T) %s", c.Type(), c, c.Name())
 
@@ -140,6 +141,17 @@ func (c *Common) Register(ctx context.Context, cfg sdk.ServiceConfig) error {
 	return nil
 }
 
+// Unregister logout the service
+func (c *Common) Unregister(ctx context.Context) error {
+	// no logout needed for api
+	if c.ServiceType == "api" {
+		return nil
+	}
+
+	log.Info(ctx, "Unregisting service %s(%T) %s", c.Type(), c, c.Name())
+	return c.Client.AuthConsumerSignout()
+}
+
 // Heartbeat have to be launch as a goroutine, call DoHeartBeat each 30s
 func (c *Common) Heartbeat(ctx context.Context, status func(ctx context.Context) *sdk.MonitoringStatus) error {
 	// no heartbeat for api
diff --git a/engine/service/types.go b/engine/service/types.go
index e675dbb96..b50b64d74 100644
--- a/engine/service/types.go
+++ b/engine/service/types.go
@@ -120,6 +120,7 @@ type Service interface {
 	Start(ctx context.Context, cfg cdsclient.ServiceConfig) error
 	Init(cfg interface{}) (cdsclient.ServiceConfig, error)
 	Register(ctx context.Context, cfg sdk.ServiceConfig) error
+	Unregister(ctx context.Context) error
 	Heartbeat(ctx context.Context, status func(ctx context.Context) *sdk.MonitoringStatus) error
 	Status(ctx context.Context) *sdk.MonitoringStatus
 	NamedService
diff --git a/engine/ui/types.go b/engine/ui/types.go
index 93c142692..6e681de28 100644
--- a/engine/ui/types.go
+++ b/engine/ui/types.go
@@ -1,6 +1,8 @@
 package ui
 
 import (
+	"net/http"
+
 	"github.com/ovh/cds/engine/api"
 	"github.com/ovh/cds/engine/service"
 )
@@ -10,6 +12,7 @@ type Service struct {
 	service.Common
 	Cfg     Configuration
 	Router  *api.Router
+	Server  *http.Server
 	HTMLDir string
 	DocsDir string
 }
diff --git a/engine/ui/ui.go b/engine/ui/ui.go
index f8901d1f9..24e64c815 100644
--- a/engine/ui/ui.go
+++ b/engine/ui/ui.go
@@ -94,7 +94,7 @@ func (s *Service) CheckConfiguration(config interface{}) error {
 }
 
 // Serve will start the http ui server
-func (s *Service) Serve(ctx context.Context) error {
+func (s *Service) BeforeStart(ctx context.Context) error {
 	log.Info(ctx, "ui> Starting service %s %s...", s.Cfg.Name, sdk.VERSION)
 	s.StartupTime = time.Now()
 
@@ -121,7 +121,7 @@ func (s *Service) Serve(ctx context.Context) error {
 
 	//Init the http server
 	s.initRouter(ctx)
-	server := &http.Server{
+	s.Server = &http.Server{
 		Addr:           fmt.Sprintf("%s:%d", s.Cfg.HTTP.Addr, s.Cfg.HTTP.Port),
 		Handler:        s.Router.Mux,
 		ReadTimeout:    10 * time.Minute,
@@ -130,15 +130,21 @@ func (s *Service) Serve(ctx context.Context) error {
 	}
 
 	// Start the http server
-	log.Info(ctx, "ui> Starting HTTP Server on port %d", s.Cfg.HTTP.Port)
-	if err := server.ListenAndServe(); err != nil {
-		log.Error(ctx, "ui> Listen and serve failed: %s", err)
-	}
+	s.GoRoutines.Run(ctx, "ui-http-serve", func(ctx context.Context) {
+		log.Info(ctx, "ui> Starting HTTP Server on port %d", s.Cfg.HTTP.Port)
+		if err := s.Server.ListenAndServe(); err != nil {
+			log.Error(ctx, "ui> Listen and serve failed: %s", err)
+		}
+	})
 
+	return nil
+}
+
+func (s *Service) Serve(ctx context.Context) error {
 	// Gracefully shutdown the http server
 	<-ctx.Done()
 	log.Info(ctx, "ui> Shutdown HTTP Server")
-	if err := server.Shutdown(ctx); err != nil {
+	if err := s.Server.Shutdown(ctx); err != nil {
 		return fmt.Errorf("unable to shutdown server: %v", err)
 	}
 
diff --git a/sdk/cdsclient/http_websocket.go b/sdk/cdsclient/http_websocket.go
index c6f6f0fcc..9430f6a8b 100644
--- a/sdk/cdsclient/http_websocket.go
+++ b/sdk/cdsclient/http_websocket.go
@@ -60,7 +60,7 @@ func (c *client) RequestWebsocket(ctx context.Context, goRoutines *sdk.GoRoutine
 		for {
 			select {
 			case <-ctx.Done():
-				log.Warning(wsContext, "Leaving....")
+				log.Warning(wsContext, "websocket goroutine leaving....")
 				return
 			case m := <-msgToSend:
 				if err := con.WriteJSON(m); err != nil {
diff --git a/sdk/common.go b/sdk/common.go
index 266d636d2..59b914ad3 100644
--- a/sdk/common.go
+++ b/sdk/common.go
@@ -267,6 +267,15 @@ func (s *Int64Slice) Remove(v int64) {
 	*s = filtered
 }
 
+func StringIsAscii(s string) bool {
+	for _, r := range s {
+		if r > unicode.MaxASCII {
+			return false
+		}
+	}
+	return true
+}
+
 func RemoveNotPrintableChar(in string) string {
 	m := func(r rune) rune {
 		switch {
diff --git a/ui/src/app/app.component.html b/ui/src/app/app.component.html
index 47aab773c..98e013cad 100644
--- a/ui/src/app/app.component.html
+++ b/ui/src/app/app.component.html
@@ -1,5 +1,21 @@
 <div id="AppComponent">
-    <ng-container *ngIf="!isConnected || !maintenance || user?.ring === 'ADMIN'">
+    <ng-container *ngIf="!isAPIAvailable">
+        <div class="page">
+            <div class="content">
+                <div class="wrapper loading">
+                    <app-scrollview class="scrollview">
+                        <div class="ui padded centered grid">
+                            <div class="column">
+                                <img class="logo ui centered image grayscale" src="assets/images/cds.png" alt="CDS Logo">
+                                <p>{{ 'cdsapi_loading' | translate }}</p>
+                            </div>
+                        </div>
+                    </app-scrollview>
+                </div>                
+            </div>
+        </div>
+    </ng-container>
+    <ng-container *ngIf="isAPIAvailable && (!isConnected || !maintenance || user?.ring === 'ADMIN')">
         <ng-container *ngIf="isConnected && !hideNavBar">
             <app-navbar></app-navbar>
         </ng-container>
@@ -23,7 +39,7 @@
             </div>
         </div>
     </ng-container>
-    <ng-container *ngIf="maintenance && isConnected && user?.ring !== 'ADMIN'">
+    <ng-container *ngIf="isAPIAvailable && maintenance && isConnected && user?.ring !== 'ADMIN'">
         <div class="maintenance">
             <h2>{{ 'maintenance_title' | translate }}</h2>
             <div class="logo">
diff --git a/ui/src/app/app.component.scss b/ui/src/app/app.component.scss
index 93838ddcd..5956cab40 100644
--- a/ui/src/app/app.component.scss
+++ b/ui/src/app/app.component.scss
@@ -56,6 +56,40 @@
         height: 450px;
         margin: auto;
     }
+
+    .loading {
+        background-color: $darkBackground;
+    
+        .column {
+            width: 600px;
+        }
+    
+        .logo {
+            margin: 60px auto;
+            height: 100px;
+        }
+
+
+        .grayscale {
+            animation: grayscale 10s infinite;
+            -webkit-animation: grayscale 10s infinite;
+        }
+        
+        @keyframes grayscale {
+            0%, 100%  { -webkit-filter: grayscale(0%); }
+            50% { -webkit-filter: grayscale(100%); }
+        }
+
+        @-webkit-keyframes grayscale {
+            0%, 100%  { -webkit-filter: grayscale(0%); }
+            50% { -webkit-filter: grayscale(100%); }
+        }
+
+        p {
+            text-align: center;
+            color: #e6e6e6;
+        }
+    }
 }
 
 @keyframes slidein {
diff --git a/ui/src/app/app.component.ts b/ui/src/app/app.component.ts
index d5d750e2e..15944d120 100644
--- a/ui/src/app/app.component.ts
+++ b/ui/src/app/app.component.ts
@@ -35,6 +35,7 @@ declare var PACMAN: any;
 @AutoUnsubscribe()
 export class AppComponent implements OnInit, OnDestroy {
     open: boolean;
+    isAPIAvailable: boolean;
     isConnected: boolean;
     hideNavBar: boolean;
     heartbeatToken: number;
@@ -77,6 +78,7 @@ export class AppComponent implements OnInit, OnDestroy {
         private _ngZone: NgZone,
         private _monitoringService: MonitoringService
     ) {
+        this.isAPIAvailable = false;
         this.zone = new NgZone({ enableLongStackTrace: false });
         this.toasterConfigDefault = this._toastService.getConfigDefault();
         this.toasterConfigErrorHTTP = this._toastService.getConfigErrorHTTP();
@@ -110,15 +112,25 @@ export class AppComponent implements OnInit, OnDestroy {
                 this.hideNavBar = e.url.startsWith('/auth')
             }
         });
-
-        this._helpService.getHelp().subscribe(h => {
-            this._store.dispatch(new AddHelp(h));
-        });
     }
 
     ngOnDestroy(): void {} // Should be set to use @AutoUnsubscribe with AOT
 
     ngOnInit(): void {
+        this._monitoringService.getStatus().subscribe(
+            (data) => {
+                this.isAPIAvailable = true;
+                this.load();
+            },
+            err => {
+                this.isAPIAvailable = false;
+                setTimeout(() => { window.location.reload() }, 30000);
+            }
+        );
+    }
+
+    load(): void {
+        this._helpService.getHelp().subscribe(h => this._store.dispatch(new AddHelp(h)));
         this._store.dispatch(new GetCDSStatus());
         this._store.select(AuthenticationState.user).subscribe(user => {
             if (!user) {
diff --git a/ui/src/app/shared/workflow/modal/delete/workflow.node.delete.component.ts b/ui/src/app/shared/workflow/modal/delete/workflow.node.delete.component.ts
index 6ba161e11..db8578fd7 100644
--- a/ui/src/app/shared/workflow/modal/delete/workflow.node.delete.component.ts
+++ b/ui/src/app/shared/workflow/modal/delete/workflow.node.delete.component.ts
@@ -31,6 +31,7 @@ export class WorkflowDeleteNodeComponent {
 
     deleteNode(): void {
         let clonedWorkflow = cloneDeep(this.workflow);
+        clonedWorkflow.notifications = cloneDeep(this.workflow.notifications);
         if (this.deleteAll === 'only') {
             Workflow.removeNodeOnly(clonedWorkflow, this.node.id);
         } else {
diff --git a/ui/src/app/views/workflow/show/notification/form/workflow.notification.form.component.ts b/ui/src/app/views/workflow/show/notification/form/workflow.notification.form.component.ts
index 6e4ec563d..cd423b27d 100644
--- a/ui/src/app/views/workflow/show/notification/form/workflow.notification.form.component.ts
+++ b/ui/src/app/views/workflow/show/notification/form/workflow.notification.form.component.ts
@@ -113,6 +113,12 @@ export class WorkflowNotificationFormComponent implements OnInit {
 
     createNotification(): void {
         this.loading = true;
+        this.notification.node_id = [];
+        this.notification.source_node_ref.forEach(source =>  {
+                let n = Workflow.getAllNodes(this.workflow).find(p => p.name === source );
+                this.notification.node_id.push(n.id);
+            }
+        );
 
         if (this.selectedUsers != null) {
             this.notification.settings.recipients = this.selectedUsers.split(',');
diff --git a/ui/src/assets/i18n/en.json b/ui/src/assets/i18n/en.json
index 107f22ae5..3e6289df2 100644
--- a/ui/src/assets/i18n/en.json
+++ b/ui/src/assets/i18n/en.json
@@ -56,6 +56,7 @@
   "action_btn_init_from_job": "Init a new action from job",
   "action_help_add_from": "Current action was initialized with data from \"{{jobName}}\" job of \"{{pipelineName}}\" pipeline in \"{{projectKey}}\" project. The data will not be stored until you save the action.",
   "admin_queue_title": "Current CDS jobs queue",
+  "cdsapi_loading": "CDS API is starting. Please wait.",
   "application_ascode_updated": "Draft updated",
   "application_home_history": "History",
   "application_home_repo": "GIT repository",
diff --git a/ui/src/assets/i18n/fr.json b/ui/src/assets/i18n/fr.json
index 8c5d50a15..7ac56798b 100644
--- a/ui/src/assets/i18n/fr.json
+++ b/ui/src/assets/i18n/fr.json
@@ -56,6 +56,7 @@
   "action_usage_warning_action": "L'action parente ne sera pas en mesure d'exécuter cette action car elles ne font pas parties du même groupe.",
   "action_usage_warning_pipeline": "Ce pipeline ne sera pas en mesure d'exécuter cette action car la permission pour le groupe n'existe pas.",
   "admin_queue_title": "File d'attente",
+  "cdsapi_loading": "CDS API est en cours de démarrage. Merci de patienter.",
   "application_ascode_updated": "Brouillon mis à jour",
   "application_create": "Créer une application",
   "application_created": "Application créée",


Test patch:
diff --git a/sdk/common_test.go b/sdk/common_test.go
index acd62275e..55d672c74 100644
--- a/sdk/common_test.go
+++ b/sdk/common_test.go
@@ -7,6 +7,11 @@ import (
 	"github.com/stretchr/testify/assert"
 )
 
+func TestStringIsAscii(t *testing.T) {
+	assert.True(t, StringIsAscii("aaa"))
+	assert.False(t, StringIsAscii("aaa 🚀"))
+}
+
 func TestRemoveNotPrintableChar(t *testing.T) {
 	var tests = []struct {
 		in  string