Skip to content

Commit 744ab62

Browse files
committed
Adding SnapshotLock Capabilities
1 parent 87f31ef commit 744ab62

File tree

12 files changed

+806
-5
lines changed

12 files changed

+806
-5
lines changed

docs/snapshot.md

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@
33
|------------------------------------|-----------------------------------------------------------|
44
| fastSnapshotRestoreAvailabilityZones | Comma separated list of availability zones |
55
| outpostArn | Arn of the outpost you wish to have the snapshot saved to |
6+
| snapshotLockEnabled | Enable snapshot locking (true/false) |
7+
| snapshotLockMode | Lock mode (governance/compliance) |
8+
| snapshotLockDuration | Lock duration in days |
9+
| snapshotLockExpirationDate | Lock expiration date (RFC3339 format) |
10+
| snapshotLockCoolOffPeriod | Cool-off period in hours (compliance mode only) |
611

712
The AWS EBS CSI Driver supports [tagging](tagging.md) through `VolumeSnapshotClass.parameters` (in v1.6.0 and later).
813
## Prerequisites
@@ -44,6 +49,51 @@ parameters:
4449

4550
The driver will attempt to check if the availability zones provided are supported for fast snapshot restore before attempting to create the snapshot. If the `EnableFastSnapshotRestores` API call fails, the driver will hard-fail the request and delete the snapshot. This is to ensure that the snapshot is not left in an inconsistent state.
4651

52+
# Snapshot Lock
53+
54+
The EBS CSI Driver provides support for [EBS Snapshot Lock](https://docs.aws.amazon.com/ebs/latest/userguide/ebs-snapshot-lock.html) via `VolumeSnapshotClass.parameters`. Snapshot locking protects snapshots from accidental or malicious deletion.
55+
56+
Snapshot locking supports two modes:
57+
- **Governance mode**: Allows privileged users to unlock snapshots
58+
- **Compliance mode**: Prevents unlocking until expiration (with optional cool-off period)
59+
60+
Lock duration can be specified using either:
61+
- `snapshotLockDuration`: Duration in days
62+
- `snapshotLockExpirationDate`: Specific expiration date in RFC3339 format
63+
64+
**Example - Governance Mode with Duration**
65+
```yaml
66+
apiVersion: snapshot.storage.k8s.io/v1
67+
kind: VolumeSnapshotClass
68+
metadata:
69+
name: csi-aws-vsc-locked
70+
driver: ebs.csi.aws.com
71+
deletionPolicy: Delete
72+
parameters:
73+
snapshotLockEnabled: "true"
74+
snapshotLockMode: "governance"
75+
snapshotLockDuration: "7"
76+
```
77+
78+
**Example - Compliance Mode with Expiration Date**
79+
```yaml
80+
apiVersion: snapshot.storage.k8s.io/v1
81+
kind: VolumeSnapshotClass
82+
metadata:
83+
name: csi-aws-vsc-compliance
84+
driver: ebs.csi.aws.com
85+
deletionPolicy: Delete
86+
parameters:
87+
snapshotLockEnabled: "true"
88+
snapshotLockMode: "compliance"
89+
snapshotLockExpirationDate: "2026-12-31T23:59:59Z"
90+
snapshotLockCoolOffPeriod: "24"
91+
```
92+
93+
## Failure Mode
94+
95+
If the `LockSnapshot` API call fails, the driver will hard-fail the request and delete the snapshot. This ensures that the snapshot is not left in an unlocked state when locking was explicitly requested.
96+
4797

4898
# Amazon EBS Local Snapshots on Outposts
4999

pkg/cloud/cloud.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -306,12 +306,18 @@ type ListSnapshotsResponse struct {
306306
NextToken string
307307
}
308308

309-
// SnapshotOptions represents parameters to create an EBS volume.
309+
// SnapshotOptions represents parameters to create an EBS snapshot.
310310
type SnapshotOptions struct {
311311
Tags map[string]string
312312
OutpostArn string
313313
}
314314

315+
// SnapshotLockOptions represents the snapshot lock specific parameters for locking en EBS snapshot.
316+
type SnapshotLockOptions struct {
317+
SnapshotLockEnabled bool
318+
LockSnapshotInput ec2.LockSnapshotInput
319+
}
320+
315321
// ec2ListSnapshotsResponse is a helper struct returned from the AWS API calling function to the main ListSnapshots function.
316322
type ec2ListSnapshotsResponse struct {
317323
Snapshots []types.Snapshot
@@ -1867,6 +1873,15 @@ func (c *cloud) CreateSnapshot(ctx context.Context, volumeID string, snapshotOpt
18671873
}, nil
18681874
}
18691875

1876+
func (c *cloud) LockSnapshot(ctx context.Context, lockSnapshotInput ec2.LockSnapshotInput) (*ec2.LockSnapshotOutput, error) {
1877+
klog.InfoS("Attempting to lock Snapshot", "request parameters: ", lockSnapshotInput)
1878+
response, err := c.ec2.LockSnapshot(ctx, &lockSnapshotInput)
1879+
if err != nil {
1880+
return nil, err
1881+
}
1882+
return response, nil
1883+
}
1884+
18701885
func (c *cloud) DeleteSnapshot(ctx context.Context, snapshotID string) (success bool, err error) {
18711886
request := &ec2.DeleteSnapshotInput{}
18721887
request.SnapshotId = aws.String(snapshotID)

pkg/cloud/cloud_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5320,3 +5320,56 @@ func TestCheckIfIopsIncreaseOnExpansion(t *testing.T) {
53205320
})
53215321
}
53225322
}
5323+
5324+
func TestLockSnapshot(t *testing.T) {
5325+
testCases := []struct {
5326+
name string
5327+
input ec2.LockSnapshotInput
5328+
mockError error
5329+
expectErr bool
5330+
}{
5331+
{
5332+
name: "success: API call succeeds",
5333+
input: ec2.LockSnapshotInput{
5334+
SnapshotId: aws.String("snap-test-id"),
5335+
LockMode: types.LockModeGovernance,
5336+
LockDuration: aws.Int32(1),
5337+
},
5338+
mockError: nil,
5339+
expectErr: false,
5340+
},
5341+
{
5342+
name: "fail: AWS API error is propagated",
5343+
input: ec2.LockSnapshotInput{
5344+
SnapshotId: aws.String("snap-test-id"),
5345+
},
5346+
mockError: errors.New("InvalidSnapshot.NotFound"),
5347+
expectErr: true,
5348+
},
5349+
}
5350+
5351+
for _, tc := range testCases {
5352+
t.Run(tc.name, func(t *testing.T) {
5353+
mockCtrl := gomock.NewController(t)
5354+
mockEC2 := NewMockEC2API(mockCtrl)
5355+
c := newCloud(mockEC2)
5356+
5357+
ctx := context.Background()
5358+
5359+
if tc.mockError != nil {
5360+
mockEC2.EXPECT().LockSnapshot(ctx, &tc.input).Return(nil, tc.mockError)
5361+
} else {
5362+
mockEC2.EXPECT().LockSnapshot(ctx, &tc.input).Return(&ec2.LockSnapshotOutput{}, nil)
5363+
}
5364+
5365+
_, err := c.LockSnapshot(ctx, tc.input)
5366+
5367+
if tc.expectErr {
5368+
require.Error(t, err)
5369+
require.Equal(t, tc.mockError.Error(), err.Error())
5370+
} else {
5371+
assert.NoError(t, err)
5372+
}
5373+
})
5374+
}
5375+
}

pkg/cloud/interface.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,4 +42,5 @@ type Cloud interface {
4242
AvailabilityZones(ctx context.Context) (map[string]struct{}, error)
4343
DryRun(ctx context.Context) error
4444
GetInstancesPatching(ctx context.Context, nodeIDs []string) ([]*types.Instance, error)
45+
LockSnapshot(ctx context.Context, lockOptions ec2.LockSnapshotInput) (*ec2.LockSnapshotOutput, error)
4546
}

pkg/cloud/mock_cloud.go

Lines changed: 15 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/cloud/mock_ec2.go

Lines changed: 20 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

pkg/driver/constants.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -126,6 +126,21 @@ const (
126126
const (
127127
// FastSnapshotRestoreAvailabilityZones represents key for fast snapshot restore availability zones.
128128
FastSnapshotRestoreAvailabilityZones = "fastsnapshotrestoreavailabilityzones"
129+
130+
// SnapshotLockEnabled represents a key for indicating whether snapshot lock is enabled or disabled.
131+
SnapshotLockEnabled = "snapshotlockenabled"
132+
133+
// SnapshotLockMode represents a key for indicating whether snapshots are locked in Governance or Compliance mode.
134+
SnapshotLockMode = "snapshotlockmode"
135+
136+
// SnapshotLockDuration is a key for the duration for which to lock the snapshots, specified in days.
137+
SnapshotLockDuration = "snapshotlockduration"
138+
139+
// SnapshotLockExpirationDate is a key for specifying the expiration date for the snapshot lock, specified in the format "YYYY-MM-DDThh:mm:ss.sssZ".
140+
SnapshotLockExpirationDate = "snapshotlockexpirationdate"
141+
142+
// SnapshotLockCoolOffPeriod is a key specifying the cooling-off period for compliance mode, specified in hours.
143+
SnapshotLockCoolOffPeriod = "snapshotlockcooloffperiod"
129144
)
130145

131146
// constants for volume tags and their values.

pkg/driver/controller.go

Lines changed: 43 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -23,8 +23,11 @@ import (
2323
"maps"
2424
"strconv"
2525
"strings"
26+
"time"
2627

28+
"github.com/aws/aws-sdk-go-v2/aws"
2729
"github.com/aws/aws-sdk-go-v2/aws/arn"
30+
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
2831
"github.com/awslabs/volume-modifier-for-k8s/pkg/rpc"
2932
csi "github.com/container-storage-interface/spec/lib/go/csi"
3033
"github.com/kubernetes-sigs/aws-ebs-csi-driver/pkg/cloud"
@@ -859,6 +862,7 @@ func (d *ControllerService) CreateSnapshot(ctx context.Context, req *csi.CreateS
859862
var vscTags []string
860863
var fsrAvailabilityZones []string
861864
vsProps := new(template.VolumeSnapshotProps)
865+
vsLock := new(cloud.SnapshotLockOptions)
862866
for key, value := range req.GetParameters() {
863867
switch strings.ToLower(key) {
864868
case VolumeSnapshotNameKey:
@@ -876,6 +880,28 @@ func (d *ControllerService) CreateSnapshot(ctx context.Context, req *csi.CreateS
876880
} else {
877881
return nil, status.Errorf(codes.InvalidArgument, "Invalid parameter value %s is not a valid arn", value)
878882
}
883+
case SnapshotLockEnabled:
884+
vsLock.SnapshotLockEnabled = isTrue(value)
885+
case SnapshotLockMode:
886+
vsLock.LockSnapshotInput.LockMode = types.LockMode(value)
887+
case SnapshotLockDuration:
888+
lockDuration, err := strconv.ParseInt(value, 10, 32)
889+
if err != nil {
890+
return nil, status.Errorf(codes.InvalidArgument, "Could not parse SnapshotLockDuration: %q", value)
891+
}
892+
vsLock.LockSnapshotInput.LockDuration = aws.Int32(int32(lockDuration))
893+
case SnapshotLockExpirationDate:
894+
expirationDate, err := time.Parse(time.RFC3339, value)
895+
if err != nil {
896+
return nil, status.Errorf(codes.InvalidArgument, "Could not parse SnapshotLockExpirationDate: %q", value)
897+
}
898+
vsLock.LockSnapshotInput.ExpirationDate = &expirationDate
899+
case SnapshotLockCoolOffPeriod:
900+
lockCoolOffPeriod, err := strconv.ParseInt(value, 10, 32)
901+
if err != nil {
902+
return nil, status.Errorf(codes.InvalidArgument, "Could not parse SnapshotLockCoolOffPeriod: %q", value)
903+
}
904+
vsLock.LockSnapshotInput.CoolOffPeriod = aws.Int32(int32(lockCoolOffPeriod))
879905
default:
880906
if strings.HasPrefix(key, TagKeyPrefix) {
881907
vscTags = append(vscTags, value)
@@ -936,12 +962,18 @@ func (d *ControllerService) CreateSnapshot(ctx context.Context, req *csi.CreateS
936962
if len(fsrAvailabilityZones) > 0 {
937963
_, err := d.cloud.EnableFastSnapshotRestores(ctx, fsrAvailabilityZones, snapshot.SnapshotID)
938964
if err != nil {
939-
if _, deleteErr := d.cloud.DeleteSnapshot(ctx, snapshot.SnapshotID); deleteErr != nil {
940-
return nil, status.Errorf(codes.Internal, "Could not delete snapshot ID %q: %v", snapshotName, deleteErr)
941-
}
942-
return nil, status.Errorf(codes.Internal, "Failed to create Fast Snapshot Restores for snapshot ID %q: %v", snapshotName, err)
965+
return nil, d.cleanupSnapshotOnError(ctx, snapshot.SnapshotID, snapshotName, err, "Failed to create Fast Snapshot Restores")
943966
}
944967
}
968+
969+
if vsLock.SnapshotLockEnabled {
970+
vsLock.LockSnapshotInput.SnapshotId = &snapshot.SnapshotID
971+
_, err := d.cloud.LockSnapshot(ctx, vsLock.LockSnapshotInput)
972+
if err != nil {
973+
return nil, d.cleanupSnapshotOnError(ctx, snapshot.SnapshotID, snapshotName, err, "Failed to lock snapshot")
974+
}
975+
}
976+
945977
return newCreateSnapshotResponse(snapshot), nil
946978
}
947979

@@ -1299,3 +1331,10 @@ func validateFormattingOption(volumeCapabilities []*csi.VolumeCapability, paramN
12991331
func isTrue(value string) bool {
13001332
return value == trueStr
13011333
}
1334+
1335+
func (d *ControllerService) cleanupSnapshotOnError(ctx context.Context, snapshotID, snapshotName string, originalErr error, errorMsg string) error {
1336+
if _, deleteErr := d.cloud.DeleteSnapshot(ctx, snapshotID); deleteErr != nil {
1337+
return status.Errorf(codes.Internal, "Could not delete snapshot ID %q: %v", snapshotName, deleteErr)
1338+
}
1339+
return status.Errorf(codes.Internal, "%s for snapshot ID %q: %v", errorMsg, snapshotName, originalErr)
1340+
}

0 commit comments

Comments
 (0)