diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go index 465a409129330506b5a7a9c9ebca9a14e5f10bee..3e0f7bb62a0be1d2fbef334322a6cb315060af8a 100644 --- a/pkg/environments/environment.go +++ b/pkg/environments/environment.go @@ -6,6 +6,18 @@ const ( DefaultEnvironment = "master" ) +var ( + ReadAllowedStates = []State{ + StateNew, + StateReady, + } + + WriteAllowedStates = []State{ + StateNew, + StateReady, + } +) + type State int const ( diff --git a/pkg/environments/service.go b/pkg/environments/service.go index cd0a37f449819a4b92239f8d8b38a3a0036ec8d8..920fc043704f9d7dcf51d83032d98292be758550 100644 --- a/pkg/environments/service.go +++ b/pkg/environments/service.go @@ -2,6 +2,11 @@ package environments import ( "context" + "slices" + "time" + + "git.perx.ru/perxis/perxis-go/pkg/errors" + "github.com/avast/retry-go/v4" ) // Environments @@ -18,3 +23,51 @@ type Environments interface { RemoveAlias(ctx context.Context, spaceId, envId, alias string) (err error) Migrate(ctx context.Context, spaceId, envId string, options ...*MigrateOptions) (err error) } + +const ( + RetryDelay = 1 * time.Second + RetryAttempts uint = 1200 +) + +// IsAvailable проверяет доступность окружения. +// Если окружение недоступно, возвращает ошибку ErrEnvironmentUnavailable +func IsAvailable(ctx context.Context, svc Environments, spaceID string, environmentID string) error { + env, err := svc.Get(ctx, spaceID, environmentID) + if err != nil { + return err + } + if env.StateInfo == nil || slices.Contains(WriteAllowedStates, env.StateInfo.State) { + return nil + } + return errors.WithContext(ErrEnvironmentUnavailable, "state", env.StateInfo.State) +} + +// WaitForAvailable периодически проверяет состояние окружения, РїРѕРєР° РѕРЅРѕ РЅРµ станет доступным. +// Если окружение становится доступным, функция возвращает nil. +// Если достигнуто максимальное количество попыток, функция возвращает ошибку. +// +// Через необязательные параметры РјРѕР¶РЅРѕ, например, настроить задержку между попытками (РїРѕ умолчанию равную RetryDelay) Рё максимальное количество попыток (РїРѕ умолчанию равное RetryAttempts), +// РЅРѕ опции retry.Context Рё retry.RetryIf Р±СѓРґСѓС‚ проигнорированы. +func WaitForAvailable(ctx context.Context, svc Environments, spaceID string, environmentID string, opts ...retry.Option) error { + opts = slices.Concat( + []retry.Option{ + retry.DelayType(retry.FixedDelay), + retry.Delay(RetryDelay), + retry.Attempts(RetryAttempts), + }, + opts, + // Чтобы предотвратить изменение параметров пользователем, размещаем РёС… РІ конце СЃРїРёСЃРєР°. + []retry.Option{ + retry.Context(ctx), + retry.RetryIf(func(err error) bool { + return errors.Is(err, ErrEnvironmentUnavailable) + }), + }, + ) + return retry.Do( + func() error { + return IsAvailable(ctx, svc, spaceID, environmentID) + }, + opts..., + ) +} diff --git a/pkg/environments/service_test.go b/pkg/environments/service_test.go new file mode 100644 index 0000000000000000000000000000000000000000..9a3e6b06ec81e3ab8e84cefdfb20aec22c7213af --- /dev/null +++ b/pkg/environments/service_test.go @@ -0,0 +1,111 @@ +package environments + +import ( + "context" + "testing" + "time" + + "github.com/avast/retry-go/v4" + "github.com/stretchr/testify/assert" +) + +type dummyEnvironments struct { + Environments + environment *Environment + sleep time.Duration +} + +func (t *dummyEnvironments) Get(ctx context.Context, _ string, _ string) (*Environment, error) { + if t.sleep != 0 { + time.Sleep(t.sleep) + if err := ctx.Err(); err != nil { + return nil, err + } + } + return t.environment, nil +} + +func TestIsAvailable(t *testing.T) { + tests := []struct { + name string + environment *Environment + wantErr bool + }{ + { + "Environment has nil StateInfo: available", + &Environment{ID: "env-id", SpaceID: "space-id"}, + false, + }, + { + "Environment state is StateReady: available", + &Environment{ID: "env-id", SpaceID: "space-id", StateInfo: &StateInfo{State: StateReady}}, + false, + }, + { + "Environment state is StatePreparing: not available", + &Environment{ID: "env-id", SpaceID: "space-id", StateInfo: &StateInfo{State: StatePreparing}}, + true, + }, + { + "Environment state is StateError: not available", + &Environment{ID: "env-id", SpaceID: "space-id", StateInfo: &StateInfo{State: StateError}}, + true, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + environments := &dummyEnvironments{environment: tt.environment} + if err := IsAvailable(context.Background(), environments, "space-id", "master"); (err != nil) != tt.wantErr { + t.Errorf("IsAvailable() error = %v, wantErr %v", err, tt.wantErr) + } + }) + } +} + +func TestWaitForAvailable(t *testing.T) { + t.Run("Success", func(t *testing.T) { + env := &Environment{ID: "env-id", SpaceID: "space-id", StateInfo: &StateInfo{State: StatePreparing}} + environments := &dummyEnvironments{environment: env} + + // Рмитируем завершение подготовки окружения через 1 секунду + go func() { + time.Sleep(1 * time.Second) + env.StateInfo = &StateInfo{State: StateReady} + }() + + err := WaitForAvailable(context.Background(), environments, "space-id", "master") + assert.NoError(t, err) + }) + t.Run("Overwritten retry options", func(t *testing.T) { + env := &Environment{ID: "env-id", SpaceID: "space-id", StateInfo: &StateInfo{State: StatePreparing}} + environments := &dummyEnvironments{environment: env} + + err := WaitForAvailable(context.Background(), environments, "space-id", "master", retry.Attempts(1)) + assert.Error(t, err, "Р—Р° РѕРґРЅСѓ попытку окружение РЅРµ стало доступным, получаем ошибку") + }) + t.Run("External context was canceled", func(t *testing.T) { + env := &Environment{ID: "env-id", SpaceID: "space-id", StateInfo: &StateInfo{State: StatePreparing}} + environments := &dummyEnvironments{environment: env} + + ctx, cancel := context.WithCancel(context.Background()) + + // Отменяем контекст через 1 секунду + go func() { + time.Sleep(1 * time.Second) + cancel() + }() + + err := WaitForAvailable(ctx, environments, "space-id", "master") + assert.ErrorIs(t, err, context.Canceled) + }) + t.Run("External context was deadline exceeded", func(t *testing.T) { + env := &Environment{ID: "env-id", SpaceID: "space-id", StateInfo: &StateInfo{State: StatePreparing}} + environments := &dummyEnvironments{environment: env} + + ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) + defer cancel() + + err := WaitForAvailable(ctx, environments, "space-id", "master") + assert.ErrorIs(t, err, context.DeadlineExceeded) + }) +}