Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add basic deployment lock implementation #252

Merged
merged 25 commits into from
Feb 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion cmd/argo-watcher/auth/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
13 changes: 7 additions & 6 deletions cmd/argo-watcher/auth/keycloak.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import (
)

type KeycloakResponse struct {
Groups []string `json:"groups"`
Username string `json:"preferred_username"`
Groups []string `json:"groups"`
}

type KeycloakAuthService struct {
Expand Down Expand Up @@ -65,27 +66,27 @@ 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)
}

// 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
}
38 changes: 20 additions & 18 deletions cmd/argo-watcher/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
31 changes: 31 additions & 0 deletions cmd/argo-watcher/config/parser_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
18 changes: 18 additions & 0 deletions cmd/argo-watcher/config/parsers.go
Original file line number Diff line number Diff line change
@@ -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
}

Check warning on line 15 in cmd/argo-watcher/config/parsers.go

View check run for this annotation

Codecov / codecov/patch

cmd/argo-watcher/config/parsers.go#L14-L15

Added lines #L14 - L15 were not covered by tests
*ls = *temp
return nil
}
59 changes: 59 additions & 0 deletions cmd/argo-watcher/server/cron.go
Original file line number Diff line number Diff line change
@@ -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)
shini4i marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
log.Error().Msgf("Couldn't parse duration for cron job. Got the following error: %s", err)
return
}

Check warning on line 50 in cmd/argo-watcher/server/cron.go

View check run for this annotation

Codecov / codecov/patch

cmd/argo-watcher/server/cron.go#L40-L50

Added lines #L40 - L50 were not covered by tests

_, 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)
}

Check warning on line 55 in cmd/argo-watcher/server/cron.go

View check run for this annotation

Codecov / codecov/patch

cmd/argo-watcher/server/cron.go#L52-L55

Added lines #L52 - L55 were not covered by tests
}

c.Start()

Check warning on line 58 in cmd/argo-watcher/server/cron.go

View check run for this annotation

Codecov / codecov/patch

cmd/argo-watcher/server/cron.go#L58

Added line #L58 was not covered by tests
}
22 changes: 22 additions & 0 deletions cmd/argo-watcher/server/cron_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
Loading
Loading