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

Writable snapshot support #92

Closed
wants to merge 10 commits into from
9 changes: 5 additions & 4 deletions api/v1/api_v1_quotas.go
Original file line number Diff line number Diff line change
Expand Up @@ -123,10 +123,11 @@ func CreateIsiQuota(
ctx context.Context,
client api.Client,
path string, container bool, size, softLimit, advisoryLimit, softGracePrd int64,
includeSnapshots bool,
) (string, error) {
// PAPI call: POST https://1.2.3.4:8080/platform/1/quota/quotas
// { "enforced" : true,
// "include_snapshots" : false,
// "include_snapshots" : true,
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

better to retain the current behavior unless it is really required. Snapshot doesn't consume much space- maybe a few bytes for the metadata, and the size is not directly related to the source volume size.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Writable snaphots could diverge. I do agree that we probably should not include snapshots but this is in goscaleio so the work is already done to expose this so let the applications decide on the business rules. Does not hurt to keep it.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Although by exposing this we break compatibility. Terraform uses this module as well.

// "path" : "/ifs/volumes/volume_name",
// "container" : true,
// "thresholds_include_overhead" : false,
Expand All @@ -136,7 +137,7 @@ func CreateIsiQuota(
// "soft" : null
// }
// }
// body={'path': '/ifs/data/quotatest', 'thresholds': {'soft_grace': 86400L, 'soft': 1048576L}, 'include_snapshots': False, 'force': False, 'type': 'directory'}
// body={'path': '/ifs/data/quotatest', 'thresholds': {'soft_grace': 86400L, 'soft': 1048576L}, 'include_snapshots': True, 'force': False, 'type': 'directory'}
// softGrace := 86400U
thresholds := isiThresholdsReq{Advisory: advisoryLimit, Hard: size, Soft: softLimit, SoftGrace: softGracePrd}
if advisoryLimit == 0 {
Expand All @@ -150,7 +151,7 @@ func CreateIsiQuota(
}
data := &IsiQuotaReq{
Enforced: true,
IncludeSnapshots: false,
IncludeSnapshots: includeSnapshots,
Path: path,
Container: container,
ThresholdsIncludeOverhead: false,
Expand All @@ -170,7 +171,7 @@ func SetIsiQuotaHardThreshold(
client api.Client,
path string, size, softLimit, advisoryLimit, softGracePrd int64,
) (string, error) {
return CreateIsiQuota(ctx, client, path, false, size, softLimit, advisoryLimit, softGracePrd)
return CreateIsiQuota(ctx, client, path, false, size, softLimit, advisoryLimit, softGracePrd, true)
}

// UpdateIsiQuotaHardThreshold modifies the hard threshold of a quota for a directory
Expand Down
3 changes: 2 additions & 1 deletion api/v14/api_v14.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@ limitations under the License.
package v14

const (
clusterAcsPath = "platform/14/cluster/acs"
clusterAcsPath = "platform/14/cluster/acs"
writableSnapshotPath = "platform/14/snapshot/writable"
)
122 changes: 122 additions & 0 deletions api/v14/api_v14_snapshots.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
/*
Copyright (c) 2025 Dell Inc, or its subsidiaries.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v14

import (
"context"
"fmt"

"github.com/dell/goisilon/api"
)

// GetIsiWritableSnapshots retrieves a list of writable snapshots.
//
// ctx: the context.
// client: the API client.
// Returns a list of writable snapshots and an error in case of failure.
func GetIsiWritableSnapshots(
ctx context.Context,
client api.Client,
) ([]*IsiWritableSnapshot, error) {
var resp *IsiWritableSnapshotQueryResponse
err := client.Get(ctx, writableSnapshotPath, "", nil, nil, &resp)
if err != nil {
return nil, fmt.Errorf("failed to get writable snapshots from array: %v", err)
}

result := make([]*IsiWritableSnapshot, 0, resp.Total)
result = append(result, resp.Writable...)

for {
if resp.Resume == "" {
break
}

query := api.OrderedValues{
{[]byte("resume"), []byte(resp.Resume)},
}

resp = nil
err = client.Get(ctx, writableSnapshotPath, "", query, nil, &resp)
if err != nil {
return nil, fmt.Errorf("failed to get writable snapshots (query mode) from array: %v", err)
}

result = append(result, resp.Writable...)
}

return result, err
}

// GetIsiWritableSnapshot retrieves a writable snapshot.
//
// ctx: the context.
// client: the API client.
// snapshotPath: the path of the snapshot.
//
// Returns the snapshot on success and error in case of failure.
func GetIsiWritableSnapshot(
ctx context.Context,
client api.Client,
snapshotPath string,
) (*IsiWritableSnapshot, error) {
var resp *IsiWritableSnapshotQueryResponse
err := client.Get(ctx, writableSnapshotPath+snapshotPath, "", nil, nil, &resp)
if err != nil {
return nil, fmt.Errorf("failed to get writable snapshot: %v", err)
}

return resp.Writable[0], nil
}

// CreateWritableSnapshot creates a writable snapshot.
//
// ctx: the context.
// client: the API client.
// sourceSnapshot: the source snapshot name or ID.
// destination: the destination path, must not be nested under the source snapshot.
//
// Returns the response and error.
func CreateWritableSnapshot(
ctx context.Context,
client api.Client,
sourceSnapshot string,
destination string,
) (resp *IsiWritableSnapshot, err error) {
// PAPI calls: PUT https://1.2.3.4:8080//platform/14/snapshot/writable
// Body: {"src_snap": sourceSnapshot, "dst_path": destination}

body := map[string]string{
"src_snap": sourceSnapshot,
"dst_path": destination,
}

err = client.Post(ctx, writableSnapshotPath, "", nil, nil, body, &resp)
if err != nil {
return nil, fmt.Errorf("failed to create writable snapshot: %v", err)
}

return resp, err
}

func RemoveWritableSnapshot(
ctx context.Context,
client api.Client,
snapshotPath string,
) error {
return client.Delete(ctx, writableSnapshotPath+snapshotPath, "", nil, nil, nil)
}
173 changes: 173 additions & 0 deletions api/v14/api_v14_snapshots_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
/*
Copyright (c) 2025 Dell Inc, or its subsidiaries.

Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at

http://www.apache.org/licenses/LICENSE-2.0

Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/

package v14

import (
"context"
"errors"
"testing"

"github.com/dell/goisilon/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)

func TestGetWritableSnapshots_ErrorCase1(t *testing.T) {
ctx := context.Background()
client := &mocks.Client{}
client.On("Get", anyArgs...).Return(errors.New("unauthorized")).Once()
_, err := GetIsiWritableSnapshots(ctx, client)
assert.NotNil(t, err)
}

func TestGetWritableSnapshots_ErrorCase2(t *testing.T) {
ctx := context.Background()
client := &mocks.Client{}

client.On("Get", anyArgs[0:6]...).Return(nil).Run(func(args mock.Arguments) {
resp := args.Get(5).(**IsiWritableSnapshotQueryResponse)
*resp = &IsiWritableSnapshotQueryResponse{
Writable: []*IsiWritableSnapshot{
{
ID: 100,
},
},
Total: 2048,
Resume: "resume",
}
}).Once()

client.On("Get", anyArgs...).Return(errors.New("invalid")).Once()
_, err := GetIsiWritableSnapshots(ctx, client)
assert.NotNil(t, err)
}

func TestGetWritableSnapshots(t *testing.T) {
ctx := context.Background()
client := &mocks.Client{}

client.On("Get", anyArgs[0:6]...).Return(nil).Run(func(args mock.Arguments) {
resp := args.Get(5).(**IsiWritableSnapshotQueryResponse)
*resp = &IsiWritableSnapshotQueryResponse{
Writable: []*IsiWritableSnapshot{
{
ID: 100,
},
},
Total: 2048,
Resume: "resume",
}
}).Once()

client.On("Get", anyArgs[0:6]...).Return(nil).Run(func(args mock.Arguments) {
resp := args.Get(5).(**IsiWritableSnapshotQueryResponse)
*resp = &IsiWritableSnapshotQueryResponse{
Writable: []*IsiWritableSnapshot{
{
ID: 1000000,
},
},
Total: 1,
Resume: "",
}
}).Once()

result, err := GetIsiWritableSnapshots(ctx, client)
assert.Nil(t, err)
assert.Len(t, result, 2)
assert.Equal(t, int64(100), result[0].ID)
}

func TestGetWritableSnapshot(t *testing.T) {
ctx := context.Background()
client := &mocks.Client{}

client.On("Get", anyArgs[0:6]...).Return(nil).Run(func(args mock.Arguments) {
resp := args.Get(5).(**IsiWritableSnapshotQueryResponse)
*resp = &IsiWritableSnapshotQueryResponse{
Writable: []*IsiWritableSnapshot{
{
ID: 100,
},
},
}
}).Once()

result, err := GetIsiWritableSnapshot(ctx, client, "/ifs/data1")
assert.Nil(t, err)
assert.Equal(t, int64(100), result.ID)

client.On("Get", anyArgs...).Return(errors.New("not found")).Once()
result, err = GetIsiWritableSnapshot(ctx, client, "/ifs/data1")
assert.NotNil(t, err)
}

func TestCreateWritableSnapshot(t *testing.T) {
ctx := context.Background()
client := &mocks.Client{}

snapshotPath := "/ifs/data1"
sourceSnapshot := "snapshot_source_1"
destinationPath := "/ifs/data2"

client.On("Post", anyArgs[0:7]...).Return(nil).Run(func(args mock.Arguments) {
body := args.Get(5).(map[string]string)
assert.Equal(t, sourceSnapshot, body["src_snap"])
assert.Equal(t, destinationPath, body["dst_path"])

resp := args.Get(6).(**IsiWritableSnapshot)
*resp = &IsiWritableSnapshot{
ID: 100,
SrcPath: snapshotPath,
DstPath: destinationPath,
SrcSnap: sourceSnapshot,
State: WritableSnapshotStateActive,
LogSize: 100,
PhysicalSize: 200,
}
}).Once()

result, err := CreateWritableSnapshot(ctx, client, sourceSnapshot, destinationPath)
assert.Nil(t, err)
assert.Equal(t, int64(100), result.ID)
assert.Equal(t, snapshotPath, result.SrcPath)
assert.Equal(t, destinationPath, result.DstPath)
assert.Equal(t, sourceSnapshot, result.SrcSnap)
assert.Equal(t, WritableSnapshotStateActive, result.State)
assert.Equal(t, int64(100), result.LogSize)
assert.Equal(t, int64(200), result.PhysicalSize)

// Test case: error in API call
client.On("Post", anyArgs[0:7]...).Return(errors.New("API call failed")).Once()

result, err = CreateWritableSnapshot(ctx, client, sourceSnapshot, destinationPath)
assert.ErrorContains(t, err, "API call failed")
assert.Nil(t, result)
}

func TestRemoveWritableSnapshot(t *testing.T) {
ctx := context.Background()
client := &mocks.Client{}

client.On("Delete", anyArgs[0:6]...).Return(nil).Run(func(args mock.Arguments) {
tgt := args.Get(1).(string)
assert.Equal(t, "platform/14/snapshot/writable/ifs/data1", tgt)
}).Once()

err := RemoveWritableSnapshot(ctx, client, "/ifs/data1")
assert.Nil(t, err)
}
37 changes: 37 additions & 0 deletions api/v14/api_v14_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,40 @@ type IsiClusterAcs struct {
// list of unresponsive nodes serial number.
UnresponsiveSn []string `json:"unresponsive_sn,omitempty"`
}

const (
WritableSnapshotStateActive = "active"
WritableSnapshotStateDeleting = "deleting"
)

// An IsiWritableSnapshot is a writable snapshot.
type IsiWritableSnapshot struct {
// The Unix Epoch time the writable snapshot was created.
Created int64 `json:"created"`
// The /ifs path of user supplied source snapshot. This will be null for writable snapshots pending delete.
SrcPath string `json:"src_path"`
// The user supplied /ifs path of writable snapshot.
DstPath string `json:"dst_path"`
// The system ID given to the writable snapshot.
ID int64 `json:"id"`
// The system ID of the user supplied source snapshot.
SrcID int64 `json:"src_id"`
// The user supplied source snapshot name or ID. This will be null for writable snapshots pending delete.
SrcSnap string `json:"src_snap"`
// The sum in bytes of logical size of files in this writable snapshot.
LogSize int64 `json:"log_size"`
// The amount of storage in bytes used to store this writable snapshot.
PhysicalSize int64 `json:"phys_size"`
// Writable Snapshot state. [WritableSnapshotStateActive, WritableSnapshotStateDeleting]
State string `json:"state"`
}

// IsiWritableSnapshotQueryResponse is the response to a writable snapshot query.
type IsiWritableSnapshotQueryResponse struct {
// Total number of items available.
Total int64 `json:"total,omitempty"`
// Used to continue a query. This is null for the last page.
Resume string `json:"resume,omitempty"`
// List of writable snapshots.
Writable []*IsiWritableSnapshot `json:"writable"`
}
Loading