diff --git a/cmd/argo-watcher/auth/auth.go b/cmd/argo-watcher/auth/auth.go index 4cb0cb0d..342c4f56 100644 --- a/cmd/argo-watcher/auth/auth.go +++ b/cmd/argo-watcher/auth/auth.go @@ -3,7 +3,7 @@ package auth type ExternalAuthService interface { Init(url, realm, clientId string, privilegedGroups []string) Validate(token string) (bool, error) - allowedToRollback(groups []string) bool + allowedToRollback(username string, groups []string) bool } func NewExternalAuthService() ExternalAuthService { diff --git a/cmd/argo-watcher/auth/keycloak.go b/cmd/argo-watcher/auth/keycloak.go index 79d2509b..96772a3f 100644 --- a/cmd/argo-watcher/auth/keycloak.go +++ b/cmd/argo-watcher/auth/keycloak.go @@ -12,7 +12,8 @@ import ( ) type KeycloakResponse struct { - Groups []string `json:"groups"` + Username string `json:"preferred_username"` + Groups []string `json:"groups"` } type KeycloakAuthService struct { @@ -65,12 +66,12 @@ func (k *KeycloakAuthService) Validate(token string) (bool, error) { } } - userPrivileged := k.allowedToRollback(keycloakResponse.Groups) + userPrivileged := k.allowedToRollback(keycloakResponse.Username, keycloakResponse.Groups) if resp.StatusCode == http.StatusOK && userPrivileged { return true, nil } else if resp.StatusCode == http.StatusOK && !userPrivileged { - return false, fmt.Errorf("user is not a member of any of the privileged groups") + return false, fmt.Errorf("%s is not a member of any of the privileged groups", keycloakResponse.Username) } return false, fmt.Errorf("token validation failed with status: %v", resp.Status) @@ -78,14 +79,14 @@ func (k *KeycloakAuthService) Validate(token string) (bool, error) { // allowedToRollback checks if the user is a member of any of the privileged groups // It duplicates the logic from fronted just to be sure that the user did not generate the request manually -func (k *KeycloakAuthService) allowedToRollback(groups []string) bool { +func (k *KeycloakAuthService) allowedToRollback(username string, groups []string) bool { for _, group := range groups { if helpers.Contains(k.PrivilegedGroups, group) { - log.Debug().Msgf("User is a member of the privileged group: %v", group) + log.Debug().Msgf("%s is a member of the privileged group: %v", username, group) return true } } - log.Debug().Msgf("User is not a member of any of the privileged groups: %v", k.PrivilegedGroups) + log.Debug().Msgf("%s is not a member of any of the privileged groups: %v", username, k.PrivilegedGroups) return false } diff --git a/cmd/argo-watcher/config/config.go b/cmd/argo-watcher/config/config.go index 0706598d..9cf699f7 100644 --- a/cmd/argo-watcher/config/config.go +++ b/cmd/argo-watcher/config/config.go @@ -28,24 +28,26 @@ type DatabaseConfig struct { } type ServerConfig struct { - ArgoUrl url.URL `env:"ARGO_URL,required" json:"argo_cd_url"` - ArgoUrlAlias string `env:"ARGO_URL_ALIAS" json:"argo_cd_url_alias,omitempty"` // Used to generate App URL. Can be omitted if ArgoUrl is reachable from outside. - ArgoToken string `env:"ARGO_TOKEN,required" json:"-"` - ArgoApiTimeout int64 `env:"ARGO_API_TIMEOUT" envDefault:"60" json:"argo_api_timeout"` - AcceptSuspendedApp bool `env:"ACCEPT_SUSPENDED_APP" envDefault:"false" json:"accept_suspended_app"` // If true, we will accept "Suspended" health status as valid - DeploymentTimeout uint `env:"DEPLOYMENT_TIMEOUT" envDefault:"900" json:"deployment_timeout"` - ArgoRefreshApp bool `env:"ARGO_REFRESH_APP" envDefault:"true" json:"argo_refresh_app"` - RegistryProxyUrl string `env:"DOCKER_IMAGES_PROXY" json:"registry_proxy_url,omitempty"` - StateType string `env:"STATE_TYPE,required" validate:"oneof=postgres in-memory" json:"state_type"` - StaticFilePath string `env:"STATIC_FILES_PATH" envDefault:"static" json:"-"` - SkipTlsVerify bool `env:"SKIP_TLS_VERIFY" envDefault:"false" json:"skip_tls_verify"` - LogLevel string `env:"LOG_LEVEL" envDefault:"info" json:"log_level"` - LogFormat string `env:"LOG_FORMAT" envDefault:"json" json:"-"` - Host string `env:"HOST" envDefault:"0.0.0.0" json:"-"` - Port string `env:"PORT" envDefault:"8080" json:"-"` - DeployToken string `env:"ARGO_WATCHER_DEPLOY_TOKEN" json:"-"` - Db DatabaseConfig `json:"db,omitempty"` - Keycloak KeycloakConfig `json:"keycloak,omitempty"` + ArgoUrl url.URL `env:"ARGO_URL,required" json:"argo_cd_url"` + ArgoUrlAlias string `env:"ARGO_URL_ALIAS" json:"argo_cd_url_alias,omitempty"` // Used to generate App URL. Can be omitted if ArgoUrl is reachable from outside. + ArgoToken string `env:"ARGO_TOKEN,required" json:"-"` + ArgoApiTimeout int64 `env:"ARGO_API_TIMEOUT" envDefault:"60" json:"argo_api_timeout"` + AcceptSuspendedApp bool `env:"ACCEPT_SUSPENDED_APP" envDefault:"false" json:"accept_suspended_app"` // If true, we will accept "Suspended" health status as valid + DeploymentTimeout uint `env:"DEPLOYMENT_TIMEOUT" envDefault:"900" json:"deployment_timeout"` + ArgoRefreshApp bool `env:"ARGO_REFRESH_APP" envDefault:"true" json:"argo_refresh_app"` + RegistryProxyUrl string `env:"DOCKER_IMAGES_PROXY" json:"registry_proxy_url,omitempty"` + StateType string `env:"STATE_TYPE,required" validate:"oneof=postgres in-memory" json:"state_type"` + StaticFilePath string `env:"STATIC_FILES_PATH" envDefault:"static" json:"-"` + SkipTlsVerify bool `env:"SKIP_TLS_VERIFY" envDefault:"false" json:"skip_tls_verify"` + LogLevel string `env:"LOG_LEVEL" envDefault:"info" json:"log_level"` + LogFormat string `env:"LOG_FORMAT" envDefault:"json" json:"-"` + Host string `env:"HOST" envDefault:"0.0.0.0" json:"-"` + Port string `env:"PORT" envDefault:"8080" json:"-"` + DeployToken string `env:"ARGO_WATCHER_DEPLOY_TOKEN" json:"-"` + Db DatabaseConfig `json:"-"` + Keycloak KeycloakConfig `json:"keycloak,omitempty"` + ScheduledLockdownEnabled bool `env:"SCHEDULED_LOCKDOWN_ENABLED" envDefault:"false" json:"scheduled_lockdown_enabled"` + LockdownSchedule LockdownSchedules `env:"LOCKDOWN_SCHEDULE" json:"-"` } // NewServerConfig parses the server configuration from environment variables using the envconfig package. diff --git a/cmd/argo-watcher/config/parser_test.go b/cmd/argo-watcher/config/parser_test.go new file mode 100644 index 00000000..8d1a02fc --- /dev/null +++ b/cmd/argo-watcher/config/parser_test.go @@ -0,0 +1,31 @@ +package config + +import ( + "testing" + + "github.com/shini4i/argo-watcher/internal/models" + "github.com/stretchr/testify/assert" +) + +func TestLockdownSchedules_UnmarshalText(t *testing.T) { + jsonString := `[ + {"cron": "*/2 * * * *", "duration": "2h"}, + {"cron": "*/20 * * * *", "duration": "3h"} + ]` + expectedLockdownSchedules := LockdownSchedules{ + models.LockdownSchedule{ + Cron: "*/2 * * * *", + Duration: "2h", + }, + models.LockdownSchedule{ + Cron: "*/20 * * * *", + Duration: "3h", + }, + } + + var lockdownSchedules LockdownSchedules + err := lockdownSchedules.UnmarshalText([]byte(jsonString)) + + assert.NoError(t, err) + assert.Equal(t, expectedLockdownSchedules, lockdownSchedules) +} diff --git a/cmd/argo-watcher/config/parsers.go b/cmd/argo-watcher/config/parsers.go new file mode 100644 index 00000000..68024c63 --- /dev/null +++ b/cmd/argo-watcher/config/parsers.go @@ -0,0 +1,18 @@ +package config + +import ( + "encoding/json" + + "github.com/shini4i/argo-watcher/internal/models" +) + +type LockdownSchedules []models.LockdownSchedule + +func (ls *LockdownSchedules) UnmarshalText(text []byte) error { + temp := &[]models.LockdownSchedule{} + if err := json.Unmarshal(text, temp); err != nil { + return err + } + *ls = *temp + return nil +} diff --git a/cmd/argo-watcher/server/cron.go b/cmd/argo-watcher/server/cron.go new file mode 100644 index 00000000..7d7b8965 --- /dev/null +++ b/cmd/argo-watcher/server/cron.go @@ -0,0 +1,59 @@ +package server + +import ( + "time" + + "github.com/robfig/cron/v3" + "github.com/rs/zerolog/log" + "github.com/shini4i/argo-watcher/cmd/argo-watcher/config" +) + +// SetDeployLockCron is a function that sets a deploy lock and sends a notification to all active WebSocket clients. +// It first sets the deployLockSet field of the Env struct to true, indicating that a deploy lock is set. +// It then sends a "locked" message to all active WebSocket clients using the notifyWebSocketClients function. +// Finally, it starts a new goroutine that will release the deploy lock after a specified duration by calling the ReleaseDeployLockCron function. +// The duration parameter specifies the duration for which the deploy lock should be set. +func (env *Env) SetDeployLockCron(duration time.Duration) { + log.Debug().Msgf("Setting automatic deploy lock for %s", duration.String()) + env.deployLockSet = true + notifyWebSocketClients("locked") + go env.ReleaseDeployLockCron(duration) +} + +// ReleaseDeployLockCron is a function that releases a previously set deploy lock after a specified duration and sends a notification to all active WebSocket clients. +// It first sleeps for the duration specified by the releaseAfter parameter. +// After the sleep duration has passed, it sets the deployLockSet field of the Env struct to false, indicating that the deploy lock has been released. +// It then sends an "unlocked" message to all active WebSocket clients using the notifyWebSocketClients function. +func (env *Env) ReleaseDeployLockCron(releaseAfter time.Duration) { + time.Sleep(releaseAfter) + log.Debug().Msg("Releasing deploy lock") + env.deployLockSet = false + notifyWebSocketClients("unlocked") +} + +// SetCron is a function that sets up cron jobs based on the provided lockdown schedules. +// It first creates a new cron.Cron instance. +// Then, for each schedule in the provided schedules, it parses the duration of the schedule and adds a new cron job to the cron.Cron instance. +// The cron job, when triggered, will call the SetDeployLockCron function with the parsed duration. +// If there's an error while parsing the duration or adding the cron job, it logs the error and returns. +// Finally, it starts the cron.Cron instance, which will start triggering the cron jobs at their specified times. +func (env *Env) SetCron(schedules config.LockdownSchedules) { + log.Debug().Msg("Setting up cron jobs") + + c := cron.New() + + for _, schedule := range schedules { + duration, err := time.ParseDuration(schedule.Duration) + if err != nil { + log.Error().Msgf("Couldn't parse duration for cron job. Got the following error: %s", err) + return + } + + _, err = c.AddFunc(schedule.Cron, func() { env.SetDeployLockCron(duration) }) + if err != nil { + log.Error().Msgf("Couldn't add cron job to set deploy lock. Got the following error: %s", err) + } + } + + c.Start() +} diff --git a/cmd/argo-watcher/server/cron_test.go b/cmd/argo-watcher/server/cron_test.go new file mode 100644 index 00000000..5bd8757a --- /dev/null +++ b/cmd/argo-watcher/server/cron_test.go @@ -0,0 +1,22 @@ +package server + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +func TestCronLock(t *testing.T) { + env := &Env{} + + t.Run("SetDeployLockCron", func(t *testing.T) { + env.SetDeployLockCron(2 * time.Second) + assert.Equal(t, true, env.deployLockSet) + }) + + t.Run("Sleep and check if the lock is released", func(t *testing.T) { + time.Sleep(3 * time.Second) + assert.Equal(t, false, env.deployLockSet) + }) +} diff --git a/cmd/argo-watcher/server/router.go b/cmd/argo-watcher/server/router.go index 1c4df166..fe9eb168 100644 --- a/cmd/argo-watcher/server/router.go +++ b/cmd/argo-watcher/server/router.go @@ -1,9 +1,12 @@ package server import ( + "context" + "errors" "fmt" "net/http" "strconv" + "sync" "time" "github.com/shini4i/argo-watcher/cmd/argo-watcher/auth" @@ -21,10 +24,21 @@ import ( "github.com/shini4i/argo-watcher/internal/models" swaggerFiles "github.com/swaggo/files" ginSwagger "github.com/swaggo/gin-swagger" + "nhooyr.io/websocket" ) var version = "local" +var ( + connectionsMutex sync.Mutex + connections []*websocket.Conn +) + +const ( + deployLockEndpoint = "/deploy-lock" + unauthorizedMessage = "You are not authorized to perform this action" +) + // Env reference: https://www.alexedwards.net/blog/organising-database-access type Env struct { // environment configurations @@ -37,6 +51,8 @@ type Env struct { metrics *prometheus.Metrics // auth service auth auth.ExternalAuthService + // deploy lock + deployLockSet bool } // CreateRouter initialize router. @@ -58,6 +74,7 @@ func (env *Env) CreateRouter() *gin.Engine { router.GET("/healthz", env.healthz) router.GET("/metrics", prometheusHandler()) router.GET("/swagger/*any", ginSwagger.WrapHandler(swaggerFiles.Handler)) + router.GET("/ws", env.handleWebSocketConnection) v1 := router.Group("/api/v1") { @@ -67,6 +84,9 @@ func (env *Env) CreateRouter() *gin.Engine { v1.GET("/apps", env.getApps) v1.GET("/version", env.getVersion) v1.GET("/config", env.getConfig) + v1.POST(deployLockEndpoint, env.SetDeployLock) + v1.DELETE(deployLockEndpoint, env.ReleaseDeployLock) + v1.GET(deployLockEndpoint, env.isDeployLockSet) } return router @@ -106,13 +126,14 @@ func (env *Env) getVersion(c *gin.Context) { // @Produce json // @Param task body models.Task true "Task" // @Success 202 {object} models.TaskStatus +// @Failure 406 {object} models.TaskStatus // @Router /api/v1/tasks [post]. func (env *Env) addTask(c *gin.Context) { var task models.Task err := c.ShouldBindJSON(&task) if err != nil { - log.Error().Msgf("Couldn't process new task. Got the following error: %s", err) + log.Error().Msgf("couldn't process new task, got the following error: %s", err) c.JSON(http.StatusNotAcceptable, models.TaskStatus{ Status: "invalid payload", Error: err.Error(), @@ -120,6 +141,16 @@ func (env *Env) addTask(c *gin.Context) { return } + // we need to handle cases when deploy lock is set either manually or by cron + if env.deployLockSet { + log.Warn().Msgf("deploy lock is set, rejecting the task") + c.JSON(http.StatusNotAcceptable, models.TaskStatus{ + Status: "rejected", + Error: "deployment is not allowed at the moment", + }) + return + } + // not an optimal solution, but for PoC it's fine // need to find a better way to pass the token later deployToken := c.GetHeader("ARGO_WATCHER_DEPLOY_TOKEN") @@ -132,7 +163,7 @@ func (env *Env) addTask(c *gin.Context) { if err != nil { log.Error().Msgf("couldn't validate keycloak token, got the following error: %s", err) c.JSON(http.StatusUnauthorized, models.TaskStatus{ - Status: "You are not authorized to perform this action", + Status: unauthorizedMessage, }) return } @@ -265,3 +296,152 @@ func (env *Env) healthz(c *gin.Context) { func (env *Env) getConfig(c *gin.Context) { c.JSON(http.StatusOK, env.config) } + +// SetDeployLock godoc +// @Summary Set deploy lock +// @Description Set deploy lock +// @Tags frontend +// @Success 200 {string} string +// @Router /api/v1/deploy-lock [post]. +func (env *Env) SetDeployLock(c *gin.Context) { + if err := env.validateKeycloakToken(c); err != nil { + log.Error().Msgf("couldn't release deploy lock, got the following error: %s", err) + c.JSON(http.StatusUnauthorized, models.TaskStatus{ + Status: unauthorizedMessage, + }) + return + } + + env.deployLockSet = true + + log.Debug().Msg("deploy lock is set") + + notifyWebSocketClients("locked") + + c.JSON(http.StatusOK, "deploy lock is set") +} + +// ReleaseDeployLock godoc +// @Summary Release deploy lock +// @Description Release deploy lock +// @Tags frontend +// @Success 200 {string} string +// @Router /api/v1/deploy-lock [delete]. +func (env *Env) ReleaseDeployLock(c *gin.Context) { + if err := env.validateKeycloakToken(c); err != nil { + log.Error().Msgf("couldn't release deploy lock, got the following error: %s", err) + c.JSON(http.StatusUnauthorized, models.TaskStatus{ + Status: unauthorizedMessage, + }) + return + } + + env.deployLockSet = false + + log.Debug().Msg("deploy lock is released") + + notifyWebSocketClients("unlocked") + + c.JSON(http.StatusOK, "deploy lock is released") +} + +// isDeployLockSet godoc +// @Summary Check if deploy lock is set +// @Description Check if deploy lock is set +// @Tags frontend +// @Success 200 {boolean} boolean +// @Router /api/v1/deploy-lock [get]. +func (env *Env) isDeployLockSet(c *gin.Context) { + c.JSON(http.StatusOK, env.deployLockSet) +} + +func (env *Env) validateKeycloakToken(c *gin.Context) error { + keycloakToken := c.GetHeader("Authorization") + + if keycloakToken != "" { + valid, err := env.auth.Validate(keycloakToken) + if err != nil { + return err + } + if !valid { + return errors.New("invalid Keycloak token") + } + } + + if env.config.Keycloak.Url != "" && keycloakToken == "" { + return errors.New("keycloak integration is enabled, but no token is provided") + } + + return nil +} + +// handleWebSocketConnection accepts a WebSocket connection, adds it to a slice, +// and initiates a goroutine to ping the connection regularly. If WebSocket +// acceptance fails, an error is logged. The goroutine serves to monitor +// the connection's activity and removes it from the slice if it's inactive. +func (env *Env) handleWebSocketConnection(c *gin.Context) { + conn, err := websocket.Accept(c.Writer, c.Request, nil) + if err != nil { + log.Error().Msgf("couldn't accept websocket connection, got the following error: %s", err) + } + + // Append the connection before starting the goroutine + connections = append(connections, conn) + + go env.checkConnection(conn) +} + +// checkConnection is a method for the Env struct that continuously checks the +// health of a WebSocket connection by sending periodic "heartbeat" messages. +func (env *Env) checkConnection(c *websocket.Conn) { + ticker := time.NewTicker(time.Second * 30) + defer ticker.Stop() + + for range ticker.C { + // we are not using c.Ping here, because it's not working as expected + // for some reason it's failing even if the connection is still alive + // if you know how to fix it, please open an issue or PR + if err := c.Write(context.Background(), websocket.MessageText, []byte("heartbeat")); err != nil { + removeWebSocketConnection(c) + return + } + } +} + +// notifyWebSocketClients is a function that sends a message to all active WebSocket clients. +// It iterates over the global connections slice, which contains all active WebSocket connections, +// and sends the provided message to each connection using the wsjson.Write function. +// If an error occurs while sending the message to a connection (for example, if the connection has been closed), +// it removes the connection from the connections slice to prevent further attempts to send messages to it. +func notifyWebSocketClients(message string) { + var wg sync.WaitGroup + + for _, conn := range connections { + wg.Add(1) + + go func(c *websocket.Conn, message string) { + defer wg.Done() + if err := c.Write(context.Background(), websocket.MessageText, []byte(message)); err != nil { + removeWebSocketConnection(c) + } + }(conn, message) + } + + wg.Wait() +} + +// removeWebSocketConnection is a helper function that removes a WebSocket connection +// from the global connections slice. It is used to clean up connections that are no longer active. +// The function takes a WebSocket connection as an argument and removes it from the connections slice. +// It uses a mutex to prevent concurrent access to the connections slice, ensuring thread safety. +func removeWebSocketConnection(conn *websocket.Conn) { + connectionsMutex.Lock() + defer connectionsMutex.Unlock() + + for i := range connections { + if connections[i] == conn { + connections = append(connections[:i], connections[i+1:]...) + break + } + } +} diff --git a/cmd/argo-watcher/server/router_test.go b/cmd/argo-watcher/server/router_test.go index 8b70b1c6..4a45f999 100644 --- a/cmd/argo-watcher/server/router_test.go +++ b/cmd/argo-watcher/server/router_test.go @@ -6,6 +6,10 @@ import ( "net/http/httptest" "testing" + "nhooyr.io/websocket" + + "github.com/shini4i/argo-watcher/cmd/argo-watcher/config" + "github.com/gin-gonic/gin" "github.com/stretchr/testify/assert" ) @@ -28,3 +32,66 @@ func TestGetVersion(t *testing.T) { assert.Equal(t, http.StatusOK, w.Code) assert.Equal(t, fmt.Sprintf("\"%s\"", version), w.Body.String()) } + +func TestDeployLock(t *testing.T) { + gin.SetMode(gin.TestMode) + + dummyConfig := &config.ServerConfig{} + + router := gin.Default() + env := &Env{config: dummyConfig} + + t.Run("SetDeployLock", func(t *testing.T) { + router.POST("/api/v1/deploy-lock", env.SetDeployLock) + + req, err := http.NewRequest(http.MethodPost, "/api/v1/deploy-lock", nil) + if err != nil { + t.Fatalf("Couldn't create request: %v\n", err) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "\"deploy lock is set\"", w.Body.String()) + assert.Equal(t, true, env.deployLockSet) + }) + + t.Run("ReleaseDeployLock", func(t *testing.T) { + router.DELETE("/api/v1/deploy-lock", env.ReleaseDeployLock) + + req, err := http.NewRequest(http.MethodDelete, "/api/v1/deploy-lock", nil) + if err != nil { + t.Fatalf("Couldn't create request: %v\n", err) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "\"deploy lock is released\"", w.Body.String()) + assert.Equal(t, false, env.deployLockSet) + }) + + t.Run("isDeployLockSet", func(t *testing.T) { + router.GET("/api/v1/deploy-lock", env.isDeployLockSet) + + req, err := http.NewRequest(http.MethodGet, "/api/v1/deploy-lock", nil) + if err != nil { + t.Fatalf("Couldn't create request: %v\n", err) + } + + w := httptest.NewRecorder() + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + assert.Equal(t, "false", w.Body.String()) + }) +} + +func TestRemoveWebSocketConnection(t *testing.T) { + conn := &websocket.Conn{} + connections = append(connections, conn) + removeWebSocketConnection(conn) + assert.NotContains(t, connections, conn) +} diff --git a/cmd/argo-watcher/server/server.go b/cmd/argo-watcher/server/server.go index 435b3c59..1e54a846 100644 --- a/cmd/argo-watcher/server/server.go +++ b/cmd/argo-watcher/server/server.go @@ -90,6 +90,11 @@ func RunServer() { ) } + // set up cron jobs + if env.config.ScheduledLockdownEnabled { + env.SetCron(env.config.LockdownSchedule) + } + // start the server log.Info().Msg("Starting web server") router := env.CreateRouter() diff --git a/docker-compose.yml b/docker-compose.yml index e7e99bd7..0cfda343 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -41,6 +41,7 @@ services: DB_NAME: watcher DB_PASSWORD: watcher DB_MIGRATIONS_PATH: /app/db/migrations + LOG_LEVEL: debug depends_on: migrations: condition: service_completed_successfully diff --git a/docs/git-integration.md b/docs/git-integration.md index 02bb5447..b5c24b27 100644 --- a/docs/git-integration.md +++ b/docs/git-integration.md @@ -76,3 +76,49 @@ ARGO_WATCHER_DEPLOY_TOKEN=previously_generated_token That's it! Starting from this point, Argo-Watcher will be able to commit changes to your GitOps repository. > Keep in mind, that `argo-watcher` will use the provided tag value as is, without any validation. So, it is up to user to make sure that the tag is valid and can be used for deployment. + +## Lockdown mode + +Argo-Watcher supports a deployment lock mechanism. It can be useful when you want to prevent Argo-Watcher from making changes during the maintenance window. + +There are two ways to enable the deployment lock: + +### Scheduled Lockdown mode + +In order to create a scheduled Lockdown mode, you need to set the following environment variables: + +```yaml +extraEnvs: + - name: SCHEDULED_LOCKDOWN_ENABLED + value: true + - name: LOCKDOWN_SCHEDULE + value: '[{"cron": "0 2 * * *", "duration": "5h30s"}]' +``` + +In this example, the deployments won't be allowed between 2:00 and 7:30 AM every day. + +### Manual Lockdown mode + +#### CLI + +In order to set Lockdown mode manually, you need to make POST request: + +```bash +curl -X POST https://argo-watcher.example.com/api/v1/deploy-lock +``` + +and to remove it make DELETE request: + +```bash +curl -X DELETE https://argo-watcher.example.com/api/v1/deploy-lock +``` + +> Keep in mind that it will work only if keycloak integration is not enabled. + +#### Frontend + +You can set Lockdown mode manually via the frontend. To do so, click on `Argo-Watcher` logo and press on `Lockdown Mode` switch. + +![Image title](https://raw.githubusercontent.com/shini4i/assets/main/src/argo-watcher/deployment-lock.png) + +> If you have keycloak integration enabled, you need to be a member of one of pre-defined privileged groups to be able to set deployment lock. diff --git a/go.mod b/go.mod index 17fadbe5..c73d7c1b 100644 --- a/go.mod +++ b/go.mod @@ -13,6 +13,7 @@ require ( github.com/google/uuid v1.4.0 github.com/lib/pq v1.10.9 github.com/prometheus/client_golang v1.17.0 + github.com/robfig/cron/v3 v3.0.1 github.com/rs/zerolog v1.31.0 github.com/stretchr/testify v1.8.4 github.com/swaggo/files v1.0.1 @@ -23,6 +24,7 @@ require ( gorm.io/datatypes v1.2.0 gorm.io/driver/postgres v1.5.4 gorm.io/gorm v1.25.5 + nhooyr.io/websocket v1.8.10 ) require ( diff --git a/go.sum b/go.sum index bf228e15..4cab659b 100644 --- a/go.sum +++ b/go.sum @@ -202,6 +202,8 @@ github.com/prometheus/common v0.44.0 h1:+5BrQJwiBB9xsMygAB3TNvpQKOwlkc25LbISbrdO github.com/prometheus/common v0.44.0/go.mod h1:ofAIvZbQ1e/nugmZGz4/qCb9Ap1VoSTIO7x0VV9VvuY= github.com/prometheus/procfs v0.11.1 h1:xRC8Iq1yyca5ypa9n1EZnWZkt7dwcoRPQwX/5gwaUuI= github.com/prometheus/procfs v0.11.1/go.mod h1:eesXgaPo1q7lBpVMoMy0ZOFTth9hBn4W/y0/p/ScXhY= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.11.0 h1:cWPaGQEPrBb5/AsnsZesgZZ9yb1OQ+GOISoDNXVBh4M= github.com/rogpeppe/go-internal v1.11.0/go.mod h1:ddIwULY96R17DhadqLgMfk9H9tvdUzkipdSkR5nkCZA= github.com/rs/xid v1.5.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= @@ -363,4 +365,6 @@ gorm.io/driver/sqlserver v1.4.1/go.mod h1:DJ4P+MeZbc5rvY58PnmN1Lnyvb5gw5NPzGshHD gorm.io/gorm v1.23.8/go.mod h1:l2lP/RyAtc1ynaTjFksBde/O8v9oOGIApu2/xRitmZk= gorm.io/gorm v1.25.5 h1:zR9lOiiYf09VNh5Q1gphfyia1JpiClIWG9hQaxB/mls= gorm.io/gorm v1.25.5/go.mod h1:hbnx/Oo0ChWMn1BIhpy1oYozzpM15i4YPuHDmfYtwg8= +nhooyr.io/websocket v1.8.10 h1:mv4p+MnGrLDcPlBoWsvPP7XCzTYMXP9F9eIGoKbgx7Q= +nhooyr.io/websocket v1.8.10/go.mod h1:rN9OFWIUwuxg4fR5tELlYC04bXYowCP9GX47ivo2l+c= rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4= diff --git a/internal/models/task.go b/internal/models/task.go index 4ba76d96..1a75c159 100644 --- a/internal/models/task.go +++ b/internal/models/task.go @@ -73,3 +73,8 @@ type ArgoApiErrorResponse struct { Code int32 `json:"code"` Message string `json:"message"` } + +type LockdownSchedule struct { + Cron string `json:"cron" example:"0 2 * * *"` + Duration string `json:"duration" example:"2h"` +} diff --git a/web/package-lock.json b/web/package-lock.json index 641d6338..7e2ca992 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -28,6 +28,7 @@ "react-dom": "^17.0.2", "react-router-dom": "^6.3.0", "react-scripts": "^5.0.1", + "socket.io-client": "^4.7.4", "web-vitals": "^2.1.4" } }, @@ -3510,6 +3511,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -7239,6 +7245,46 @@ "node": ">= 0.8" } }, + "node_modules/engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + } + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": "^5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -15879,6 +15925,32 @@ "node": ">=8" } }, + "node_modules/socket.io-client": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.4.tgz", + "integrity": "sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -17829,6 +17901,14 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", @@ -20178,6 +20258,11 @@ "@sinonjs/commons": "^1.7.0" } }, + "@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "@surma/rollup-plugin-off-main-thread": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/@surma/rollup-plugin-off-main-thread/-/rollup-plugin-off-main-thread-2.2.3.tgz", @@ -22908,6 +22993,31 @@ "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==" }, + "engine.io-client": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.5.3.tgz", + "integrity": "sha512-9Z0qLB0NIisTRt1DZ/8U2k12RJn8yls/nXMZLn+/N8hANT3TcYjKFKcwbw5zFQiN4NTde3TSY9zb79e1ij6j9Q==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0", + "xmlhttprequest-ssl": "~2.0.0" + }, + "dependencies": { + "ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "requires": {} + } + } + }, + "engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==" + }, "enhanced-resolve": { "version": "5.10.0", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.10.0.tgz", @@ -28983,6 +29093,26 @@ "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", "integrity": "sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==" }, + "socket.io-client": { + "version": "4.7.4", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.7.4.tgz", + "integrity": "sha512-wh+OkeF0rAVCrABWQBaEjLfb7DVPotMbu0cgWgyR0v6eA4EoVnAwcIeIbcdTE3GT/H3kbdLl7OoH2+asoDRIIg==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.5.2", + "socket.io-parser": "~4.2.4" + } + }, + "socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "requires": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + } + }, "sockjs": { "version": "0.3.24", "resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz", @@ -30471,6 +30601,11 @@ "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==" }, + "xmlhttprequest-ssl": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.0.0.tgz", + "integrity": "sha512-QKxVRxiRACQcVuQEYFsI1hhkrMlrXHPegbbd1yn9UHOmRxY+si12nQYzri3vbzt8VdTTRviqcKxcyllFas5z2A==" + }, "xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/web/package.json b/web/package.json index 2238ddb1..6ea99e8e 100644 --- a/web/package.json +++ b/web/package.json @@ -23,6 +23,7 @@ "react-dom": "^17.0.2", "react-router-dom": "^6.3.0", "react-scripts": "^5.0.1", + "socket.io-client": "^4.7.4", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/web/src/Components/Navbar.js b/web/src/Components/Navbar.js index 3c94aa72..c08da659 100644 --- a/web/src/Components/Navbar.js +++ b/web/src/Components/Navbar.js @@ -72,15 +72,15 @@ function Navbar() { - setSidebarOpen(true)}> - Argo Watcher Logo + + setSidebarOpen(true)}> + Argo Watcher Logo + - {' '} - {/* ml = margin-left: To add some space between logo and text */} Argo Watcher diff --git a/web/src/Components/Sidebar.js b/web/src/Components/Sidebar.js index 65dd5a54..ad88e6cb 100644 --- a/web/src/Components/Sidebar.js +++ b/web/src/Components/Sidebar.js @@ -1,13 +1,43 @@ -import React, { useEffect, useState } from 'react'; +import React, {useContext, useEffect, useState} from 'react'; import PropTypes from 'prop-types'; -import { Drawer, Box, Typography, TableContainer, Table, TableHead, TableRow, TableCell, TableBody, Paper, CircularProgress, Button } from '@mui/material'; -import { fetchConfig } from '../config'; +import { + Box, + Button, + CircularProgress, + Drawer, + Paper, + Table, + TableBody, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography +} from '@mui/material'; +import Switch from '@mui/material/Switch'; +import {fetchConfig} from '../config'; +import {fetchDeployLock, releaseDeployLock, setDeployLock} from '../deployLockHandler'; +import { AuthContext } from '../auth'; -function Sidebar({ open, onClose }) { +function Sidebar({open, onClose}) { const [configData, setConfigData] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); + const { authenticated, keycloakToken } = useContext(AuthContext); + const [isDeployLockSet, setIsDeployLockSet] = useState(false); + + + const toggleDeployLock = async () => { + if (isDeployLockSet) { + await releaseDeployLock(authenticated ? keycloakToken : null); + setIsDeployLockSet(false); + } else { + await setDeployLock(authenticated ? keycloakToken : null); + setIsDeployLockSet(true); + } + }; + useEffect(() => { fetchConfig() .then(data => { @@ -18,6 +48,14 @@ function Sidebar({ open, onClose }) { setError(error.message); setIsLoading(false); }); + + fetchDeployLock() + .then((data) => { + setIsDeployLockSet(data); + }) + .catch((error) => { + setError(error.message); + }) }, []); const handleCopy = () => { @@ -29,13 +67,13 @@ function Sidebar({ open, onClose }) { return `${value.Scheme}://${value.Host}${value.Path}`; } else if (value && typeof value === 'object' && value.constructor === Object) { return ( - + {JSON.stringify(value, null, 2)} ); } return ( - + {value.toString()} ); @@ -44,8 +82,8 @@ function Sidebar({ open, onClose }) { const renderContent = () => { if (isLoading) { return ( - - + + Loading... ); @@ -64,7 +102,7 @@ function Sidebar({ open, onClose }) { {Object.entries(configData).map(([key, value]) => ( - + {key} @@ -76,7 +114,7 @@ function Sidebar({ open, onClose }) { - + @@ -89,14 +127,26 @@ function Sidebar({ open, onClose }) { }; return ( - - + + Config Data {renderContent()} - + + + + Lockdown Mode + + + + + © {new Date().getFullYear()} Vadim Gedz diff --git a/web/src/Components/TasksTable.js b/web/src/Components/TasksTable.js index 725aca61..66d696de 100644 --- a/web/src/Components/TasksTable.js +++ b/web/src/Components/TasksTable.js @@ -2,7 +2,7 @@ import HelpOutlineIcon from '@mui/icons-material/HelpOutline'; import KeyboardArrowDownIcon from '@mui/icons-material/KeyboardArrowDown'; import KeyboardArrowUpIcon from '@mui/icons-material/KeyboardArrowUp'; import LaunchIcon from '@mui/icons-material/Launch'; -import { Chip, Link, MenuItem, TextField } from '@mui/material'; +import {Chip, Link, MenuItem, TextField} from '@mui/material'; import Box from '@mui/material/Box'; import IconButton from '@mui/material/IconButton'; import Pagination from '@mui/material/Pagination'; @@ -14,420 +14,469 @@ import TableHead from '@mui/material/TableHead'; import TableRow from '@mui/material/TableRow'; import Tooltip from '@mui/material/Tooltip'; import Typography from '@mui/material/Typography'; -import { addMinutes, format } from 'date-fns'; -import React, { useEffect, useState } from 'react'; -import { Link as ReactLink } from 'react-router-dom'; -import { fetchTasks } from '../Services/Data'; -import { - relativeHumanDuration, - relativeTime, - relativeTimestamp, -} from '../Utils'; +import {addMinutes, format} from 'date-fns'; +import React, {useEffect, useState} from 'react'; +import {Link as ReactLink} from 'react-router-dom'; +import {fetchTasks} from '../Services/Data'; +import {fetchDeployLock} from "../deployLockHandler"; +import {relativeHumanDuration, relativeTime, relativeTimestamp,} from '../Utils'; -export function ProjectDisplay({ project }) { - if (project.indexOf('http') === 0) { - return ( - - {project.replace(/^http(s)?:\/\//, '').replace(/\/+$/, '')} - - ); - } - return {project}; +export function ProjectDisplay({project}) { + if (project.indexOf('http') === 0) { + return ( + + {project.replace(/^http(s)?:\/\//, '').replace(/\/+$/, '')} + + ); + } + return {project}; } export const chipColorByStatus = status => { - if (status === 'in progress') { - return 'primary'; - } - if (status === 'failed') { - return 'error'; - } - if (status === 'deployed') { - return 'success'; - } - return undefined; + if (status === 'in progress') { + return 'primary'; + } + if (status === 'failed') { + return 'error'; + } + if (status === 'deployed') { + return 'success'; + } + return undefined; }; -export function StatusReasonDisplay({ reason }) { - return ( - - {reason} - - ); +export function StatusReasonDisplay({reason}) { + return ( + + {reason} + + ); } const taskDuration = (created, updated) => { - if (!updated) { - updated = Math.round(Date.now() / 1000); - } - const seconds = updated - created; - return relativeHumanDuration(seconds); + if (!updated) { + updated = Math.round(Date.now() / 1000); + } + const seconds = updated - created; + return relativeHumanDuration(seconds); }; const defaultFormatTime = '---'; export const formatDateTime = timestamp => { - if (!timestamp) { - return defaultFormatTime; - } - try { - let dateTime = new Date(timestamp * 1000); - return format( - addMinutes(dateTime, dateTime.getTimezoneOffset()), - 'yyyy/MM/dd HH:mm:ss', - ); - } catch (error) { - console.error(error); - return defaultFormatTime; - } + if (!timestamp) { + return defaultFormatTime; + } + try { + let dateTime = new Date(timestamp * 1000); + return format( + addMinutes(dateTime, dateTime.getTimezoneOffset()), + 'yyyy/MM/dd HH:mm:ss', + ); + } catch (error) { + console.error(error); + return defaultFormatTime; + } }; -export function useTasks({ setError, setSuccess }) { - const [tasks, setTasks] = useState([]); - const [sortField, setSortField] = useState({ - field: 'created', - direction: 'ASC', - }); +export function useTasks({setError, setSuccess}) { + const [tasks, setTasks] = useState([]); + const [sortField, setSortField] = useState({ + field: 'created', + direction: 'ASC', + }); - const refreshTasksInTimeframe = (timeframe, application) => { - // get tasks by timestamp - fetchTasks(relativeTimestamp(timeframe), null, application) - .then(items => { - setSuccess('fetchTasks', 'Fetched tasks successfully'); - setTasksSorted(items, sortField); - }) - .catch(error => { - setError('fetchTasks', error.message); - }); - }; + const refreshTasksInTimeframe = (timeframe, application) => { + // get tasks by timestamp + fetchTasks(relativeTimestamp(timeframe), null, application) + .then(items => { + setSuccess('fetchTasks', 'Fetched tasks successfully'); + setTasksSorted(items, sortField); + }) + .catch(error => { + setError('fetchTasks', error.message); + }); + }; - const refreshTasksInRange = (fromTimestamp, toTimestamp, application) => { - // get tasks by timestamp - fetchTasks(fromTimestamp, toTimestamp, application) - .then(items => { - setSuccess('fetchTasks', 'Fetched tasks successfully'); - setTasksSorted(items, sortField); - }) - .catch(error => { - setError('fetchTasks', error.message); - }); - }; + const refreshTasksInRange = (fromTimestamp, toTimestamp, application) => { + // get tasks by timestamp + fetchTasks(fromTimestamp, toTimestamp, application) + .then(items => { + setSuccess('fetchTasks', 'Fetched tasks successfully'); + setTasksSorted(items, sortField); + }) + .catch(error => { + setError('fetchTasks', error.message); + }); + }; - const clearTasks = () => { - setTasks([]); - }; + const clearTasks = () => { + setTasks([]); + }; - const setTasksSorted = (unsortedTasks, sort) => { - // sort tasks - unsortedTasks.sort((a, b) => { - let aField = a[sort.field]; - let bField = b[sort.field]; - if (aField === bField) { - return 0; - } - if (aField > bField) { - return sort.direction === 'ASC' ? -1 : 1; - } else { - return sort.direction === 'ASC' ? 1 : -1; - } - }); + const setTasksSorted = (unsortedTasks, sort) => { + // sort tasks + unsortedTasks.sort((a, b) => { + let aField = a[sort.field]; + let bField = b[sort.field]; + if (aField === bField) { + return 0; + } + if (aField > bField) { + return sort.direction === 'ASC' ? -1 : 1; + } else { + return sort.direction === 'ASC' ? 1 : -1; + } + }); - // save sorted tasks - setTasks([].concat(unsortedTasks)); - }; + // save sorted tasks + setTasks([].concat(unsortedTasks)); + }; - // sort field change hook - useEffect(() => { - setTasksSorted(tasks, sortField); - }, [sortField]); + // sort field change hook + useEffect(() => { + setTasksSorted(tasks, sortField); + }, [sortField]); - return { - tasks, - sortField, - setSortField, - refreshTasksInTimeframe, - refreshTasksInRange, - clearTasks, - }; + return { + tasks, + sortField, + setSortField, + refreshTasksInTimeframe, + refreshTasksInRange, + clearTasks, + }; } -function TableCellSorted({ field, sortField, setSortField, children }) { - const triggerSortChange = triggerField => { - // change sort parameters - let sortFieldChange = { ...sortField }; - if (sortFieldChange.field === triggerField) { - sortFieldChange.direction = - sortFieldChange.direction === 'ASC' ? 'DESC' : 'ASC'; - } else { - sortFieldChange.field = triggerField; - sortFieldChange.direction = 'ASC'; - } - setSortField(sortFieldChange); - }; +function TableCellSorted({field, sortField, setSortField, children}) { + const triggerSortChange = triggerField => { + // change sort parameters + let sortFieldChange = {...sortField}; + if (sortFieldChange.field === triggerField) { + sortFieldChange.direction = + sortFieldChange.direction === 'ASC' ? 'DESC' : 'ASC'; + } else { + sortFieldChange.field = triggerField; + sortFieldChange.direction = 'ASC'; + } + setSortField(sortFieldChange); + }; - return ( - { - triggerSortChange(field); - }} - sx={{ cursor: 'pointer' }} - > - - {children}{' '} - {sortField.field === field && - (sortField.direction === 'ASC' ? ( - - ) : ( - - ))} - - - ); + return ( + { + triggerSortChange(field); + }} + sx={{cursor: 'pointer'}} + > + + {children}{' '} + {sortField.field === field && + (sortField.direction === 'ASC' ? ( + + ) : ( + + ))} + + + ); } const cacheKeyItemsPerPage = 'items_per_page'; const itemsPerPageList = [10, 25, 50]; const defaultItemsPerPage = itemsPerPageList[0]; const getCachedItemsPerPage = () => { - const itemsPerPage = Number(localStorage.getItem(cacheKeyItemsPerPage)); - if (itemsPerPageList.includes(itemsPerPage)) { - return itemsPerPage; - } - return defaultItemsPerPage; + const itemsPerPage = Number(localStorage.getItem(cacheKeyItemsPerPage)); + if (itemsPerPageList.includes(itemsPerPage)) { + return itemsPerPage; + } + return defaultItemsPerPage; }; function TasksTable({ - tasks, - sortField, - setSortField, - relativeDate, - onPageChange, - page = 1, -}) { - const [itemsPerPage, setItemsPerPage] = useState(getCachedItemsPerPage()); - const [visibleReasons, setVisibleReasons] = useState([]); + tasks, + sortField, + setSortField, + relativeDate, + onPageChange, + page = 1, + }) { + const [itemsPerPage, setItemsPerPage] = useState(getCachedItemsPerPage()); + const [visibleReasons, setVisibleReasons] = useState([]); + const [deployLock, setDeployLock] = useState(false); - const toggleReason = task => { - setVisibleReasons(visibleReasons => { - if (visibleReasons.includes(task?.id)) { - return [...visibleReasons.filter(id => id !== task?.id)]; - } else { - return [...visibleReasons, task?.id]; - } - }); - }; + const checkDeployLock = async () => { + const lock = await fetchDeployLock(); + setDeployLock(lock); + }; - const pages = Math.ceil(tasks.length / itemsPerPage); - const tasksPaginated = tasks.slice( - (page - 1) * itemsPerPage, - page * itemsPerPage, - ); + useEffect(() => { + // set the initial deploy lock state + checkDeployLock(); - const handleItemsPerPageChange = event => { - setItemsPerPage(event.target.value); - localStorage.setItem(cacheKeyItemsPerPage, event.target.value); - }; + // Get the current URL + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const host = window.location.host; + const wsUrl = `${protocol}//${host}/ws`; - return ( - <> - - - - - - Id - - - Application - - - Project - - - Author - - - Status - - - Started - - - Duration - - - Images - - - - - {tasksPaginated.map(task => ( - - - - - {task.id.substring(0, 8)} - - - - {task.app} - - - - {task.author} - - - {task?.status_reason && ( - { - toggleReason(task); - }} - > - - - )} - - - {relativeDate && ( - - {relativeTime(task.created * 1000)} - - )} - {!relativeDate && ( - {formatDateTime(task.created)} - )} - - - {task.status === 'in progress' && ( - {taskDuration(task.created, null)} - )} - {task.status !== 'in progress' && ( - {taskDuration(task.created, task?.updated)} - )} - - - {task.images.map((item, index) => { - return ( -
- {item.image}:{item.tag} -
- ); - })} -
-
- {task?.status_reason && visibleReasons.includes(task?.id) && ( - { + const message = event.data; + if (message === 'locked') { + setDeployLock(true); + } else if (message === 'unlocked') { + setDeployLock(false); + } else { + console.log(`Received message is: ${message}, Type of message is: ${typeof message}`); + } + }; + + // Return cleanup function + return () => { + socket.close(); + }; + }, []); + + const toggleReason = task => { + setVisibleReasons(visibleReasons => { + if (visibleReasons.includes(task?.id)) { + return [...visibleReasons.filter(id => id !== task?.id)]; + } else { + return [...visibleReasons, task?.id]; + } + }); + }; + + const pages = Math.ceil(tasks.length / itemsPerPage); + const tasksPaginated = tasks.slice( + (page - 1) * itemsPerPage, + page * itemsPerPage, + ); + + const handleItemsPerPageChange = event => { + setItemsPerPage(event.target.value); + localStorage.setItem(cacheKeyItemsPerPage, event.target.value); + }; + + return ( + <> + +
+ + + + Id + + + Application + + + Project + + + Author + + + Status + + + Started + + + Duration + + + Images + + + + + {tasksPaginated.map(task => ( + + + + + {task.id.substring(0, 8)} + + + + {task.app} + + + + {task.author} + + + {task?.status_reason && ( + { + toggleReason(task); + }} + > + + + )} + + + {relativeDate && ( + + {relativeTime(task.created * 1000)} + + )} + {!relativeDate && ( + {formatDateTime(task.created)} + )} + + + {task.status === 'in progress' && ( + {taskDuration(task.created, null)} + )} + {task.status !== 'in progress' && ( + {taskDuration(task.created, task?.updated)} + )} + + + {task.images.map((item, index) => { + return ( +
+ {item.image}:{item.tag} +
+ ); + })} +
+
+ {task?.status_reason && visibleReasons.includes(task?.id) && ( + + + + + + )} +
+ ))} + {tasks.length === 0 && ( + + + No tasks were found within provided time frame + + + )} +
+
+
+ + { + onPageChange && onPageChange(value); }} - > - - - - - )} - - ))} - {tasks.length === 0 && ( - - - No tasks were found within provided time frame - - + /> + + {itemsPerPageList.map(value => { + return ( + + {value} + + ); + })} + + + {deployLock && ( + + Deployments are not accepted. + )} - - - - - { - onPageChange && onPageChange(value); - }} - /> - - {itemsPerPageList.map(value => { - return ( - - {value} - - ); - })} - - - - ); + + ); } export default TasksTable; diff --git a/web/src/deployLockHandler.js b/web/src/deployLockHandler.js new file mode 100644 index 00000000..af9aecbc --- /dev/null +++ b/web/src/deployLockHandler.js @@ -0,0 +1,42 @@ +export async function fetchDeployLock() { + const response = await fetch('/api/v1/deploy-lock'); + return await response.json(); +} + +export async function releaseDeployLock(keycloakToken) { + let headers = { + 'Content-Type': 'application/json' + }; + + if(keycloakToken !== null){ + headers['Authorization'] = keycloakToken; + } + + const response = await fetch('/api/v1/deploy-lock', { + method: 'DELETE', + headers: headers, + }); + + if (response.status !== 200) { + throw new Error(`Error: ${response.status}`); + } +} + +export async function setDeployLock(keycloakToken = null) { + let headers = { + 'Content-Type': 'application/json' + }; + + if(keycloakToken !== null){ + headers['Authorization'] = keycloakToken; + } + + const response = await fetch('/api/v1/deploy-lock', { + method: 'POST', + headers: headers, + }); + + if (response.status !== 200) { + throw new Error(`Error: ${response.status}`); + } +}