From 3e7c23efa6a7e42ddcdce460da22208234ab94bd Mon Sep 17 00:00:00 2001
From: Semyon Krestyaninov <krestyaninov@perx.ru>
Date: Fri, 16 Aug 2024 19:48:05 +0000
Subject: [PATCH] =?UTF-8?q?feat(core):=20=D0=94=D0=BE=D0=B1=D0=B0=D0=B2?=
 =?UTF-8?q?=D0=BB=D0=B5=D0=BD=D0=B0=20=D1=84=D1=83=D0=BD=D0=BA=D1=86=D0=B8?=
 =?UTF-8?q?=D1=8F=20=D0=B4=D0=BB=D1=8F=20=D0=BE=D0=B6=D0=B8=D0=B4=D0=B0?=
 =?UTF-8?q?=D0=BD=D0=B8=D1=8F=20=D0=B3=D0=BE=D1=82=D0=BE=D0=B2=D0=BD=D0=BE?=
 =?UTF-8?q?=D1=81=D1=82=D0=B8=20=D0=BE=D0=BA=D1=80=D1=83=D0=B6=D0=B5=D0=BD?=
 =?UTF-8?q?=D0=B8=D1=8F?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Close #PRXS-2648
---
 pkg/environments/environment.go  |  12 ++++
 pkg/environments/service.go      |  53 +++++++++++++++
 pkg/environments/service_test.go | 111 +++++++++++++++++++++++++++++++
 3 files changed, 176 insertions(+)
 create mode 100644 pkg/environments/service_test.go

diff --git a/pkg/environments/environment.go b/pkg/environments/environment.go
index 465a4091..3e0f7bb6 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 cd0a37f4..920fc043 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 00000000..9a3e6b06
--- /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)
+	})
+}
-- 
GitLab