Commit 065622c1 authored by Anatoly Stansler's avatar Anatoly Stansler 🎯

Merge branch '107-cli-choosing-snapshot' into 'master'

feat: allow choosing snapshot when creating a clone in CLI (#107)

Closes #107

See merge request !81
parents 563d014c 39ceb705
Pipeline #121285777 passed with stages
in 6 minutes and 17 seconds
......@@ -97,7 +97,7 @@ paths:
schema:
$ref: '#/definitions/CreateClone'
responses:
200:
201:
description: "Successful operation"
schema:
$ref: "#/definitions/Clone"
......@@ -172,6 +172,10 @@ paths:
schema:
$ref: '#/definitions/UpdateClone'
responses:
200:
description: "Successful operation"
schema:
$ref: "#/definitions/Clone"
404:
description: "Not found"
schema:
......
......@@ -12,7 +12,7 @@ import (
"github.com/urfave/cli/v2"
"gitlab.com/postgres-ai/database-lab/cmd/cli/commands"
"gitlab.com/postgres-ai/database-lab/pkg/client/dblabapi"
"gitlab.com/postgres-ai/database-lab/pkg/client/dblabapi/types"
"gitlab.com/postgres-ai/database-lab/pkg/models"
)
......@@ -48,16 +48,20 @@ func create() func(*cli.Context) error {
return err
}
cloneRequest := dblabapi.CreateRequest{
cloneRequest := types.CloneCreateRequest{
ID: cliCtx.String("id"),
Project: cliCtx.String("project"),
Protected: cliCtx.Bool("protected"),
DB: &dblabapi.DatabaseRequest{
DB: &types.DatabaseRequest{
Username: cliCtx.String("username"),
Password: cliCtx.String("password"),
},
}
if cliCtx.IsSet("snapshot-id") {
cloneRequest.Snapshot = &types.SnapshotCloneFieldRequest{ID: cliCtx.String("snapshot-id")}
}
var clone *models.Clone
if cliCtx.Bool("async") {
......@@ -113,7 +117,7 @@ func update() func(*cli.Context) error {
return err
}
updateRequest := dblabapi.UpdateRequest{
updateRequest := types.CloneUpdateRequest{
Protected: cliCtx.Bool("protected"),
}
......
......@@ -47,6 +47,10 @@ func CommandList() []*cli.Command {
Name: "id",
Usage: "clone ID (optional)",
},
&cli.StringFlag{
Name: "snapshot-id",
Usage: "snapshot ID (optional)",
},
&cli.StringFlag{
Name: "project",
Usage: "project name (optional)",
......
......@@ -19,7 +19,7 @@ func GlobalList() []*cli.Command {
CustomHelpTemplate: templates.CustomCommandHelpTemplate + templates.SupportProjectTemplate,
Flags: []cli.Flag{
&cli.StringFlag{
Name: "environment_id",
Name: "environment-id",
Usage: "environment ID of Database Lab instance's API",
Required: true,
},
......
......@@ -14,6 +14,7 @@ import (
"github.com/pkg/errors"
"gitlab.com/postgres-ai/database-lab/pkg/client/dblabapi/types"
"gitlab.com/postgres-ai/database-lab/pkg/models"
)
......@@ -67,27 +68,13 @@ func (c *Client) GetClone(ctx context.Context, cloneID string) (*models.Clone, e
return &clone, nil
}
// CreateRequest represents clone params of a create request.
type CreateRequest struct {
ID string `json:"id"`
Project string `json:"project"`
Protected bool `json:"protected"`
DB *DatabaseRequest `json:"db"`
}
// DatabaseRequest represents database params of a clone request.
type DatabaseRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// CreateClone creates a new Database Lab clone.
func (c *Client) CreateClone(ctx context.Context, cloneRequest CreateRequest) (*models.Clone, error) {
func (c *Client) CreateClone(ctx context.Context, cloneRequest types.CloneCreateRequest) (*models.Clone, error) {
u := c.URL("/clone")
body := bytes.NewBuffer(nil)
if err := json.NewEncoder(body).Encode(cloneRequest); err != nil {
return nil, errors.Wrap(err, "failed to encode CreateRequest")
return nil, errors.Wrap(err, "failed to encode CloneCreateRequest")
}
request, err := http.NewRequest(http.MethodPost, u.String(), body)
......@@ -161,12 +148,12 @@ func (c *Client) watchCloneStatus(ctx context.Context, cloneID string, initialSt
}
// CreateCloneAsync asynchronously creates a new Database Lab clone.
func (c *Client) CreateCloneAsync(ctx context.Context, cloneRequest CreateRequest) (*models.Clone, error) {
func (c *Client) CreateCloneAsync(ctx context.Context, cloneRequest types.CloneCreateRequest) (*models.Clone, error) {
u := c.URL("/clone")
body := bytes.NewBuffer(nil)
if err := json.NewEncoder(body).Encode(cloneRequest); err != nil {
return nil, errors.Wrap(err, "failed to encode CreateRequest")
return nil, errors.Wrap(err, "failed to encode CloneCreateRequest")
}
request, err := http.NewRequest(http.MethodPost, u.String(), body)
......@@ -190,18 +177,13 @@ func (c *Client) CreateCloneAsync(ctx context.Context, cloneRequest CreateReques
return &clone, nil
}
// UpdateRequest represents params of an update request.
type UpdateRequest struct {
Protected bool `json:"protected"`
}
// UpdateClone updates an existing Database Lab clone.
func (c *Client) UpdateClone(ctx context.Context, cloneID string, updateRequest UpdateRequest) (*models.Clone, error) {
func (c *Client) UpdateClone(ctx context.Context, cloneID string, updateRequest types.CloneUpdateRequest) (*models.Clone, error) {
u := c.URL(fmt.Sprintf("/clone/%s", cloneID))
body := bytes.NewBuffer(nil)
if err := json.NewEncoder(body).Encode(updateRequest); err != nil {
return nil, errors.Wrap(err, "failed to encode UpdateRequest")
return nil, errors.Wrap(err, "failed to encode CloneUpdateRequest")
}
request, err := http.NewRequest(http.MethodPatch, u.String(), body)
......
......@@ -13,6 +13,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"gitlab.com/postgres-ai/database-lab/pkg/client/dblabapi/types"
"gitlab.com/postgres-ai/database-lab/pkg/models"
)
......@@ -122,7 +123,7 @@ func TestClientCreateClone(t *testing.T) {
require.NoError(t, err)
defer func() { _ = r.Body.Close() }()
cloneRequest := CreateRequest{}
cloneRequest := types.CloneCreateRequest{}
err = json.Unmarshal(requestBody, &cloneRequest)
require.NoError(t, err)
clone = expectedClone
......@@ -156,11 +157,11 @@ func TestClientCreateClone(t *testing.T) {
defer cancel()
// Send a request.
newClone, err := c.CreateClone(ctx, CreateRequest{
newClone, err := c.CreateClone(ctx, types.CloneCreateRequest{
ID: "testCloneID",
Project: "testProject",
Protected: true,
DB: &DatabaseRequest{
DB: &types.DatabaseRequest{
Username: "john",
Password: "doe",
},
......@@ -198,7 +199,7 @@ func TestClientCreateCloneAsync(t *testing.T) {
require.NoError(t, err)
defer func() { _ = r.Body.Close() }()
cloneRequest := CreateRequest{}
cloneRequest := types.CloneCreateRequest{}
err = json.Unmarshal(requestBody, &cloneRequest)
require.NoError(t, err)
......@@ -226,11 +227,11 @@ func TestClientCreateCloneAsync(t *testing.T) {
defer cancel()
// Send a request.
newClone, err := c.CreateCloneAsync(ctx, CreateRequest{
newClone, err := c.CreateCloneAsync(ctx, types.CloneCreateRequest{
ID: "testCloneID",
Project: "testProject",
Protected: true,
DB: &DatabaseRequest{
DB: &types.DatabaseRequest{
Username: "john",
Password: "doe",
},
......@@ -258,7 +259,7 @@ func TestClientCreateCloneWithFailedRequest(t *testing.T) {
c.client = mockClient
clone, err := c.CreateClone(context.Background(), CreateRequest{})
clone, err := c.CreateClone(context.Background(), types.CloneCreateRequest{})
require.EqualError(t, err, "failed to decode a response body: EOF")
require.Nil(t, clone)
}
......@@ -365,7 +366,7 @@ func TestClientUpdateClone(t *testing.T) {
require.NoError(t, err)
defer func() { _ = r.Body.Close() }()
updateRequest := UpdateRequest{}
updateRequest := types.CloneUpdateRequest{}
err = json.Unmarshal(requestBody, &updateRequest)
require.NoError(t, err)
......@@ -392,7 +393,7 @@ func TestClientUpdateClone(t *testing.T) {
c.client = mockClient
// Send a request.
newClone, err := c.UpdateClone(context.Background(), cloneModel.ID, UpdateRequest{
newClone, err := c.UpdateClone(context.Background(), cloneModel.ID, types.CloneUpdateRequest{
Protected: false,
})
require.NoError(t, err)
......@@ -429,7 +430,7 @@ func TestClientUpdateCloneWithFailedRequest(t *testing.T) {
c.client = mockClient
clone, err := c.UpdateClone(context.Background(), "testCloneID", UpdateRequest{})
clone, err := c.UpdateClone(context.Background(), "testCloneID", types.CloneUpdateRequest{})
require.EqualError(t, err, `failed to get response: Code "BAD_REQUEST". Message: Wrong request format. Detail: Clone not found. Hint: Check request params.`)
require.Nil(t, clone)
}
......
/*
2020 © Postgres.ai
*/
// Package types provides request structures for Database Lab HTTP API.
package types
// CloneCreateRequest represents clone params of a create request.
type CloneCreateRequest struct {
ID string `json:"id"`
Project string `json:"project"`
Protected bool `json:"protected"`
DB *DatabaseRequest `json:"db"`
Snapshot *SnapshotCloneFieldRequest `json:"snapshot"`
}
// CloneUpdateRequest represents params of an update request.
type CloneUpdateRequest struct {
Protected bool `json:"protected"`
}
// DatabaseRequest represents database params of a clone request.
type DatabaseRequest struct {
Username string `json:"username"`
Password string `json:"password"`
}
// SnapshotCloneFieldRequest represents snapshot params of a create request.
type SnapshotCloneFieldRequest struct {
ID string `json:"id"`
}
......@@ -11,6 +11,7 @@ import (
"github.com/pkg/errors"
"gitlab.com/postgres-ai/database-lab/pkg/client/dblabapi/types"
"gitlab.com/postgres-ai/database-lab/pkg/log"
"gitlab.com/postgres-ai/database-lab/pkg/models"
"gitlab.com/postgres-ai/database-lab/pkg/services/provision"
......@@ -43,10 +44,10 @@ type cloning struct {
type Cloning interface {
Run(ctx context.Context) error
CreateClone(*models.Clone) error
CreateClone(*types.CloneCreateRequest) (*models.Clone, error)
DestroyClone(string) error
GetClone(string) (*models.Clone, error)
UpdateClone(string, *models.Clone) error
UpdateClone(string, *types.CloneUpdateRequest) (*models.Clone, error)
ResetClone(string) error
GetInstanceState() (*models.InstanceStatus, error)
......
......@@ -17,6 +17,7 @@ import (
"github.com/pkg/errors"
"github.com/rs/xid"
"gitlab.com/postgres-ai/database-lab/pkg/client/dblabapi/types"
"gitlab.com/postgres-ai/database-lab/pkg/log"
"gitlab.com/postgres-ai/database-lab/pkg/models"
"gitlab.com/postgres-ai/database-lab/pkg/services/provision"
......@@ -33,6 +34,7 @@ type baseCloning struct {
cloneMutex sync.RWMutex
clones map[string]*CloneWrapper
instanceStatus *models.InstanceStatus
snapshotMutex sync.RWMutex
snapshots []models.Snapshot
provision provision.Provision
......@@ -67,36 +69,63 @@ func (c *baseCloning) Run(ctx context.Context) error {
}
// CreateClone creates a new clone.
func (c *baseCloning) CreateClone(clone *models.Clone) error {
func (c *baseCloning) CreateClone(cloneRequest *types.CloneCreateRequest) (*models.Clone, error) {
// TODO(akartasov): Separate validation rules.
clone.ID = strings.TrimSpace(clone.ID)
cloneRequest.ID = strings.TrimSpace(cloneRequest.ID)
if _, ok := c.findWrapper(clone.ID); ok {
return errors.New("clone with such ID already exists")
if _, ok := c.findWrapper(cloneRequest.ID); ok {
return nil, errors.New("clone with such ID already exists")
}
if clone.DB == nil {
return errors.New("missing both DB username and password")
if cloneRequest.DB == nil {
return nil, errors.New("missing both DB username and password")
}
if len(clone.DB.Username) == 0 {
return errors.New("missing DB username")
if len(cloneRequest.DB.Username) == 0 {
return nil, errors.New("missing DB username")
}
if len(clone.DB.Password) == 0 {
return errors.New("missing DB password")
if len(cloneRequest.DB.Password) == 0 {
return nil, errors.New("missing DB password")
}
if clone.ID == "" {
clone.ID = xid.New().String()
if cloneRequest.ID == "" {
cloneRequest.ID = xid.New().String()
}
createdAt := time.Now()
clone.CreatedAt = util.FormatTime(createdAt)
clone.Status = &models.Status{
Code: models.StatusCreating,
Message: models.CloneMessageCreating,
err := c.fetchSnapshots()
if err != nil {
return nil, errors.Wrap(err, "failed to fetch snapshots")
}
var snapshot models.Snapshot
if cloneRequest.Snapshot != nil {
snapshot, err = c.getSnapshotByID(cloneRequest.Snapshot.ID)
} else {
snapshot, err = c.getLatestSnapshot()
}
if err != nil {
return nil, errors.Wrap(err, "failed to get snapshot")
}
clone := &models.Clone{
ID: cloneRequest.ID,
Snapshot: &snapshot,
Protected: cloneRequest.Protected,
CreatedAt: util.FormatTime(createdAt),
Status: &models.Status{
Code: models.StatusCreating,
Message: models.CloneMessageCreating,
},
DB: &models.Database{
Username: cloneRequest.DB.Username,
Password: cloneRequest.DB.Password,
},
Project: cloneRequest.Project,
}
w := NewCloneWrapper(clone)
......@@ -104,26 +133,18 @@ func (c *baseCloning) CreateClone(clone *models.Clone) error {
w.username = clone.DB.Username
w.password = clone.DB.Password
w.timeCreatedAt = createdAt
if clone.Snapshot != nil {
w.snapshot = *clone.Snapshot
}
w.snapshot = snapshot
clone.DB.Password = ""
cloneID := clone.ID
err := c.fetchSnapshots()
if err != nil {
return errors.Wrap(err, "failed to create clone")
}
c.setWrapper(clone.ID, w)
go func() {
session, err := c.provision.StartSession(w.username, w.password, w.snapshot.ID)
if err != nil {
// TODO(anatoly): Empty room case.
log.Errf("Failed to create a clone: %+v.", err)
log.Errf("Failed to start session: %v.", err)
if err := c.updateCloneStatus(cloneID, models.Status{
Code: models.StatusFatal,
......@@ -158,10 +179,6 @@ func (c *baseCloning) CreateClone(clone *models.Clone) error {
clone.DB.ConnStr = fmt.Sprintf("host=%s port=%s user=%s",
clone.DB.Host, clone.DB.Port, clone.DB.Username)
if len(c.snapshots) > 0 {
clone.Snapshot = &c.snapshots[0]
}
// TODO(anatoly): Remove mock data.
clone.Metadata = &models.CloneMetadata{
CloneSize: cloneSize,
......@@ -170,7 +187,7 @@ func (c *baseCloning) CreateClone(clone *models.Clone) error {
}
}()
return nil
return clone, nil
}
func (c *baseCloning) DestroyClone(cloneID string) error {
......@@ -238,53 +255,22 @@ func (c *baseCloning) GetClone(id string) (*models.Clone, error) {
return w.clone, nil
}
func (c *baseCloning) UpdateClone(id string, patch *models.Clone) error {
// TODO(anatoly): Nullable fields?
// Check unmodifiable fields.
if len(patch.ID) > 0 {
return errors.New("ID cannot be changed")
}
if patch.Snapshot != nil {
return errors.New("Snapshot cannot be changed")
}
if patch.Metadata != nil {
return errors.New("Metadata cannot be changed")
}
if len(patch.Project) > 0 {
return errors.New("Project cannot be changed")
}
if patch.DB != nil {
return errors.New("Database cannot be changed")
}
if patch.Status != nil {
return errors.New("Status cannot be changed")
}
if len(patch.DeleteAt) > 0 {
return errors.New("DeleteAt cannot be changed")
}
if len(patch.CreatedAt) > 0 {
return errors.New("CreatedAt cannot be changed")
}
func (c *baseCloning) UpdateClone(id string, patch *types.CloneUpdateRequest) (*models.Clone, error) {
w, ok := c.findWrapper(id)
if !ok {
return errors.New("clone not found")
return nil, errors.New("clone not found")
}
// Set fields.
var clone *models.Clone
// Set fields.
c.cloneMutex.Lock()
w.clone.Protected = patch.Protected
clone = w.clone
c.cloneMutex.Unlock()
return nil
return clone, nil
}
func (c *baseCloning) ResetClone(cloneID string) error {
......@@ -352,7 +338,13 @@ func (c *baseCloning) GetSnapshots() ([]models.Snapshot, error) {
return nil, errors.Wrap(err, "failed to fetch snapshots")
}
return c.snapshots, nil
snapshots := make([]models.Snapshot, len(c.snapshots))
c.snapshotMutex.RLock()
copy(snapshots, c.snapshots)
c.snapshotMutex.RUnlock()
return snapshots, nil
}
// GetClones returns all clones.
......@@ -453,11 +445,41 @@ func (c *baseCloning) fetchSnapshots() error {
log.Dbg("snapshot:", snapshots[i])
}
c.snapshotMutex.Lock()
c.snapshots = snapshots
c.snapshotMutex.Unlock()
return nil
}
// getLatestSnapshot returns the latest snapshot.
func (c *baseCloning) getLatestSnapshot() (models.Snapshot, error) {
c.snapshotMutex.RLock()
defer c.snapshotMutex.RUnlock()
if len(c.snapshots) == 0 {
return models.Snapshot{}, errors.New("no snapshot found")
}
snapshot := c.snapshots[0]
return snapshot, nil
}
// getSnapshotByID returns the snapshot by ID.
func (c *baseCloning) getSnapshotByID(snapshotID string) (models.Snapshot, error) {
c.snapshotMutex.RLock()
defer c.snapshotMutex.RUnlock()
for _, snapshot := range c.snapshots {
if snapshot.ID == snapshotID {
return snapshot, nil
}
}
return models.Snapshot{}, errors.New("no snapshot found")
}
func (c *baseCloning) runIdleCheck(ctx context.Context) {
if c.Config.IdleTime == 0 {
return
......
......@@ -22,7 +22,8 @@ type BaseCloningSuite struct {
func (s *BaseCloningSuite) SetupSuite() {
cloning := &baseCloning{
clones: make(map[string]*CloneWrapper),
clones: make(map[string]*CloneWrapper),
snapshots: make([]models.Snapshot, 0),
}
s.cloning = cloning
......@@ -30,6 +31,7 @@ func (s *BaseCloningSuite) SetupSuite() {
func (s *BaseCloningSuite) TearDownTest() {
s.cloning.clones = make(map[string]*CloneWrapper)
s.cloning.snapshots = make([]models.Snapshot, 0)
}
func (s *BaseCloningSuite) TestFindWrapper() {
......@@ -105,3 +107,53 @@ func (s *BaseCloningSuite) TestLenClones() {
lenClones = s.cloning.lenClones()
assert.Equal(s.T(), 1, lenClones)
}
func (s *BaseCloningSuite) TestLatestSnapshot() {
snapshot1 := models.Snapshot{
ID: "TestSnapshotID1",
CreatedAt: "2020-02-20 01:23:45",
DataStateAt: "2020-02-19 00:00:00",
}
snapshot2 := models.Snapshot{
ID: "TestSnapshotID2",
CreatedAt: "2020-02-20 05:43:21",
DataStateAt: "2020-02-20 00:00:00",
}
assert.Equal(s.T(), 0, len(s.cloning.snapshots))
latestSnapshot, err := s.cloning.getLatestSnapshot()
require.Equal(s.T(), latestSnapshot, models.Snapshot{})
assert.EqualError(s.T(), err, "no snapshot found")
s.cloning.snapshots = append(s.cloning.snapshots, snapshot1, snapshot2)
latestSnapshot, err = s.cloning.getLatestSnapshot()
require.NoError(s.T(), err)
assert.Equal(s.T(), latestSnapshot, snapshot1)
}
func (s *BaseCloningSuite) TestSnapshotByID() {
snapshot1 := models.Snapshot{
ID: "TestSnapshotID1",
CreatedAt: "2020-02-20 01:23:45",
DataStateAt: "2020-02-19 00:00:00",
}
snapshot2 := models.Snapshot{
ID: "TestSnapshotID2",
CreatedAt: "2020-02-20 05:43:21",
DataStateAt: "2020-02-20 00:00:00",
}
assert.Equal(s.T(), 0, len(s.cloning.snapshots))
latestSnapshot, err := s.cloning.getLatestSnapshot()
require.Equal(s.T(), latestSnapshot, models.Snapshot{})
assert.EqualError(s.T(), err, "no snapshot found")
s.cloning.snapshots = append(s.cloning.snapshots, snapshot1, snapshot2)
latestSnapshot, err = s.cloning.getSnapshotByID("TestSnapshotID2")
require.NoError(s.T(), err)
assert.Equal(s.T(), latestSnapshot, snapshot2)
}
......@@ -9,6 +9,7 @@ import (
"github.com/pkg/errors"
"gitlab.com/postgres-ai/database-lab/pkg/client/dblabapi/types"
"gitlab.com/postgres-ai/database-lab/pkg/models"
)
......@@ -77,8 +78,8 @@ func (c *mockCloning) Run(ctx context.Context) error {
return nil
}
func (c *mockCloning) CreateClone(clone *models.Clone) error {
return nil
func (c *mockCloning) CreateClone(clone *types.CloneCreateRequest) (*models.Clone, error) {
return &models.Clone{}, nil
}
func (c *mockCloning) DestroyClone(id string) error {
......@@ -98,12 +99,12 @@ func (c *mockCloning) GetClone(id string) (*models.Clone, error) {
return clone, nil
}
func (c *mockCloning) UpdateClone(id string, patch *models.Clone) error {
func (c *mockCloning) UpdateClone(id string, patch *types.CloneUpdateRequest) (*models.Clone, error) {
if _, ok := c.clones[id]; !ok {
return errors.New("clone not found")
return nil, errors.New("clone not found")
}
return nil
return &models.Clone{}, nil