mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
feat: snapshots (#30)
This commit is contained in:
@@ -116,11 +116,11 @@ Instance `dmesg`:
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Prerequisites | Basic Logic | Test Cases | Status |
|
||||
|---------|-------------------|---------------|---------------|-------------|------------|--------|
|
||||
| `create-snapshot` | — | `--volume-id`, `--description`, `--tag-specifications` | Volume must exist | Trigger viperblock WAL checkpoint → copy volume data to snapshot in Predastore → assign snap-ID → return snapshot with state=pending→completed | 1. Snapshot attached volume<br>2. Snapshot detached volume<br>3. Invalid volume ID (error)<br>4. Verify snapshot in describe-snapshots | **NOT STARTED** |
|
||||
| `create-snapshots` | — | `--instance-specification`, `--description`, `--tag-specifications` | Instance must exist | Create snapshots of all volumes attached to instance → return list of snapshot IDs | 1. Snapshot all volumes on instance<br>2. Instance with no volumes | **NOT STARTED** |
|
||||
| `delete-snapshot` | — | `--snapshot-id` | Snapshot must exist, not in use by AMI | Delete snapshot data from Predastore → return success | 1. Delete existing snapshot<br>2. Delete snapshot used by AMI (error)<br>3. Delete non-existent snapshot (error) | **NOT STARTED** |
|
||||
| `describe-snapshots` | — | `--snapshot-ids`, `--owner-ids`, `--filters`, `--max-results` | None | Query Predastore for snapshot metadata → return snapshot list with state, volume ID, size, progress | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Filter by volume ID | **NOT STARTED** |
|
||||
| `copy-snapshot` | — | `--source-snapshot-id`, `--source-region`, `--description`, `--encrypted` | Source snapshot must exist | Copy snapshot data between regions/nodes in Predastore → assign new snap-ID | 1. Copy within same region<br>2. Copy non-existent snapshot (error) | **NOT STARTED** |
|
||||
| `create-snapshot` | `--volume-id`, `--description`, `--tag-specifications` | `--dry-run` | Volume must exist in Predastore | Gateway validates vol- prefix → NATS `ec2.CreateSnapshot` → daemon reads VolumeConfig from Predastore → generates snap-ID via viperblock → stores SnapshotConfig (metadata-only, points to source volume) as completed → returns Snapshot with state=completed, progress=100% | 1. Snapshot from valid volume<br>2. Missing volume ID (InvalidParameterValue)<br>3. Volume not found (InvalidVolume.NotFound)<br>4. Invalid volume ID format (InvalidVolume.Malformed)<br>5. Snapshot with description<br>6. Snapshot with tag specifications | **DONE** |
|
||||
| `create-snapshots` | — | `--instance-specification`, `--description`, `--tag-specifications` | Instance must exist, instance-volume attachment tracking | Create snapshots of all volumes attached to instance → return list of snapshot IDs. Blocked: requires instance-volume attachment tracking to discover which volumes to snapshot. | 1. Snapshot all volumes on instance<br>2. Instance with no volumes | **NOT STARTED** |
|
||||
| `delete-snapshot` | `--snapshot-id` | `--dry-run` | Snapshot must exist | Gateway validates snap- prefix → NATS `ec2.DeleteSnapshot` → daemon verifies snapshot exists in Predastore → lists and deletes all objects under snapshot prefix → returns success | 1. Delete existing snapshot<br>2. Delete non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing snapshot ID (InvalidParameterValue)<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
|
||||
| `describe-snapshots` | `--snapshot-ids` | `--owner-ids`, `--filters`, `--max-results`, `--dry-run` | None | Gateway validates snap- prefix on IDs → NATS `ec2.DescribeSnapshots` → daemon lists snap- prefixed objects in Predastore → reads SnapshotConfig for each → filters by snapshot ID if specified → returns snapshot list | 1. List all snapshots<br>2. Filter by snapshot ID<br>3. Empty snapshot list<br>4. Invalid snapshot ID format (InvalidSnapshot.Malformed) | **DONE** |
|
||||
| `copy-snapshot` | `--source-snapshot-id`, `--source-region`, `--description` | `--encrypted`, `--dry-run` | Source snapshot must exist | Gateway validates snap- prefix + source region → NATS `ec2.CopySnapshot` → daemon reads source SnapshotConfig → generates new snap-ID → copies metadata (preserves tags, description override) → stores as completed → returns new snapshot ID | 1. Copy within same region<br>2. Copy non-existent snapshot (InvalidSnapshot.NotFound)<br>3. Missing source ID (InvalidParameterValue)<br>4. Missing source region (MissingParameter)<br>5. Copy preserves tags<br>6. Copy with description override | **DONE** |
|
||||
|
||||
### EC2 - Tags
|
||||
|
||||
|
||||
@@ -29,6 +29,7 @@ import (
|
||||
handlers_ec2_image "github.com/mulgadc/hive/hive/handlers/ec2/image"
|
||||
handlers_ec2_instance "github.com/mulgadc/hive/hive/handlers/ec2/instance"
|
||||
handlers_ec2_key "github.com/mulgadc/hive/hive/handlers/ec2/key"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/hive/hive/handlers/ec2/snapshot"
|
||||
handlers_ec2_tags "github.com/mulgadc/hive/hive/handlers/ec2/tags"
|
||||
"github.com/mulgadc/hive/hive/objectstore"
|
||||
handlers_ec2_volume "github.com/mulgadc/hive/hive/handlers/ec2/volume"
|
||||
@@ -79,6 +80,7 @@ type Daemon struct {
|
||||
keyService *handlers_ec2_key.KeyServiceImpl
|
||||
imageService *handlers_ec2_image.ImageServiceImpl
|
||||
volumeService *handlers_ec2_volume.VolumeServiceImpl
|
||||
snapshotService *handlers_ec2_snapshot.SnapshotServiceImpl
|
||||
tagsService *handlers_ec2_tags.TagsServiceImpl
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
@@ -446,6 +448,10 @@ func (d *Daemon) subscribeAll() error {
|
||||
{"ec2.ModifyVolume", d.handleEC2ModifyVolume, "hive-workers"},
|
||||
{"ec2.DeleteVolume", d.handleEC2DeleteVolume, "hive-workers"},
|
||||
{"ec2.DescribeVolumeStatus", d.handleEC2DescribeVolumeStatus, "hive-workers"},
|
||||
{"ec2.CreateSnapshot", d.handleEC2CreateSnapshot, "hive-workers"},
|
||||
{"ec2.DescribeSnapshots", d.handleEC2DescribeSnapshots, "hive-workers"},
|
||||
{"ec2.DeleteSnapshot", d.handleEC2DeleteSnapshot, "hive-workers"},
|
||||
{"ec2.CopySnapshot", d.handleEC2CopySnapshot, "hive-workers"},
|
||||
{"ec2.CreateTags", d.handleEC2CreateTags, "hive-workers"},
|
||||
{"ec2.DeleteTags", d.handleEC2DeleteTags, "hive-workers"},
|
||||
{"ec2.DescribeTags", d.handleEC2DescribeTags, "hive-workers"},
|
||||
@@ -495,6 +501,7 @@ func (d *Daemon) Start() error {
|
||||
d.keyService = handlers_ec2_key.NewKeyServiceImpl(d.config)
|
||||
d.imageService = handlers_ec2_image.NewImageServiceImpl(d.config)
|
||||
d.volumeService = handlers_ec2_volume.NewVolumeServiceImpl(d.config, d.natsConn)
|
||||
d.snapshotService = handlers_ec2_snapshot.NewSnapshotServiceImpl(d.config)
|
||||
d.tagsService = handlers_ec2_tags.NewTagsServiceImpl(d.config)
|
||||
|
||||
d.restoreInstances()
|
||||
|
||||
@@ -900,6 +900,22 @@ func (d *Daemon) handleEC2DeleteVolume(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.volumeService.DeleteVolume)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateSnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.CreateSnapshot)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeSnapshots(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.DescribeSnapshots)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteSnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.DeleteSnapshot)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CopySnapshot(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.snapshotService.CopySnapshot)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateTags(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.tagsService.CreateTags)
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
gateway_ec2_image "github.com/mulgadc/hive/hive/gateway/ec2/image"
|
||||
gateway_ec2_instance "github.com/mulgadc/hive/hive/gateway/ec2/instance"
|
||||
gateway_ec2_key "github.com/mulgadc/hive/hive/gateway/ec2/key"
|
||||
gateway_ec2_snapshot "github.com/mulgadc/hive/hive/gateway/ec2/snapshot"
|
||||
gateway_ec2_tags "github.com/mulgadc/hive/hive/gateway/ec2/tags"
|
||||
gateway_ec2_volume "github.com/mulgadc/hive/hive/gateway/ec2/volume"
|
||||
gateway_ec2_zone "github.com/mulgadc/hive/hive/gateway/ec2/zone"
|
||||
@@ -123,6 +124,18 @@ var ec2Actions = map[string]EC2Handler{
|
||||
"DescribeTags": ec2Handler(func(input *ec2.DescribeTagsInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_tags.DescribeTags(input, gw.NATSConn)
|
||||
}),
|
||||
"CreateSnapshot": ec2Handler(func(input *ec2.CreateSnapshotInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_snapshot.CreateSnapshot(input, gw.NATSConn)
|
||||
}),
|
||||
"DeleteSnapshot": ec2Handler(func(input *ec2.DeleteSnapshotInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_snapshot.DeleteSnapshot(input, gw.NATSConn)
|
||||
}),
|
||||
"DescribeSnapshots": ec2Handler(func(input *ec2.DescribeSnapshotsInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_snapshot.DescribeSnapshots(input, gw.NATSConn)
|
||||
}),
|
||||
"CopySnapshot": ec2Handler(func(input *ec2.CopySnapshotInput, gw *GatewayConfig) (any, error) {
|
||||
return gateway_ec2_snapshot.CopySnapshot(input, gw.NATSConn)
|
||||
}),
|
||||
}
|
||||
|
||||
func (gw *GatewayConfig) EC2_Request(ctx *fiber.Ctx) error {
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
package gateway_ec2_snapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/hive/hive/handlers/ec2/snapshot"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateCopySnapshotInput validates the input parameters for CopySnapshot
|
||||
func ValidateCopySnapshotInput(input *ec2.CopySnapshotInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
if input.SourceSnapshotId == nil || *input.SourceSnapshotId == "" {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(*input.SourceSnapshotId, "snap-") {
|
||||
return errors.New(awserrors.ErrorInvalidSnapshotIDMalformed)
|
||||
}
|
||||
|
||||
if input.SourceRegion == nil || *input.SourceRegion == "" {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CopySnapshot handles the EC2 CopySnapshot API call
|
||||
func CopySnapshot(input *ec2.CopySnapshotInput, natsConn *nats.Conn) (ec2.CopySnapshotOutput, error) {
|
||||
var output ec2.CopySnapshotOutput
|
||||
|
||||
if err := ValidateCopySnapshotInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_snapshot.NewNATSSnapshotService(natsConn)
|
||||
result, err := svc.CopySnapshot(input)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package gateway_ec2_snapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/hive/hive/handlers/ec2/snapshot"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateCreateSnapshotInput validates the input parameters for CreateSnapshot
|
||||
func ValidateCreateSnapshotInput(input *ec2.CreateSnapshotInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
if input.VolumeId == nil || *input.VolumeId == "" {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(*input.VolumeId, "vol-") {
|
||||
return errors.New(awserrors.ErrorInvalidVolumeIDMalformed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateSnapshot handles the EC2 CreateSnapshot API call
|
||||
func CreateSnapshot(input *ec2.CreateSnapshotInput, natsConn *nats.Conn) (ec2.Snapshot, error) {
|
||||
var output ec2.Snapshot
|
||||
|
||||
if err := ValidateCreateSnapshotInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_snapshot.NewNATSSnapshotService(natsConn)
|
||||
result, err := svc.CreateSnapshot(input)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package gateway_ec2_snapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/hive/hive/handlers/ec2/snapshot"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateDeleteSnapshotInput validates the input parameters for DeleteSnapshot
|
||||
func ValidateDeleteSnapshotInput(input *ec2.DeleteSnapshotInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
if input.SnapshotId == nil || *input.SnapshotId == "" {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
if !strings.HasPrefix(*input.SnapshotId, "snap-") {
|
||||
return errors.New(awserrors.ErrorInvalidSnapshotIDMalformed)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteSnapshot handles the EC2 DeleteSnapshot API call
|
||||
func DeleteSnapshot(input *ec2.DeleteSnapshotInput, natsConn *nats.Conn) (ec2.DeleteSnapshotOutput, error) {
|
||||
var output ec2.DeleteSnapshotOutput
|
||||
|
||||
if err := ValidateDeleteSnapshotInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_snapshot.NewNATSSnapshotService(natsConn)
|
||||
result, err := svc.DeleteSnapshot(input)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gateway_ec2_snapshot
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
handlers_ec2_snapshot "github.com/mulgadc/hive/hive/handlers/ec2/snapshot"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateDescribeSnapshotsInput validates the input parameters for DescribeSnapshots
|
||||
func ValidateDescribeSnapshotsInput(input *ec2.DescribeSnapshotsInput) error {
|
||||
if input == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate snapshot IDs if provided
|
||||
for _, id := range input.SnapshotIds {
|
||||
if id != nil && *id != "" && !strings.HasPrefix(*id, "snap-") {
|
||||
return errors.New(awserrors.ErrorInvalidSnapshotIDMalformed)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DescribeSnapshots handles the EC2 DescribeSnapshots API call
|
||||
func DescribeSnapshots(input *ec2.DescribeSnapshotsInput, natsConn *nats.Conn) (ec2.DescribeSnapshotsOutput, error) {
|
||||
var output ec2.DescribeSnapshotsOutput
|
||||
|
||||
if err := ValidateDescribeSnapshotsInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_snapshot.NewNATSSnapshotService(natsConn)
|
||||
result, err := svc.DescribeSnapshots(input)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
package gateway_ec2_snapshot
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestValidateCreateSnapshotInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *ec2.CreateSnapshotInput
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "NilInput",
|
||||
input: nil,
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
},
|
||||
{
|
||||
name: "EmptyInput",
|
||||
input: &ec2.CreateSnapshotInput{},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
},
|
||||
{
|
||||
name: "InvalidVolumeIdFormat",
|
||||
input: &ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("invalid-id"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidVolumeIDMalformed,
|
||||
},
|
||||
{
|
||||
name: "ValidInput",
|
||||
input: &ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-1234567890abcdef0"),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ValidInputWithDescription",
|
||||
input: &ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-1234567890abcdef0"),
|
||||
Description: aws.String("My snapshot"),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateCreateSnapshotInput(tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDescribeSnapshotsInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *ec2.DescribeSnapshotsInput
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "NilInput",
|
||||
input: nil,
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "EmptyInput",
|
||||
input: &ec2.DescribeSnapshotsInput{},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "ValidSnapshotId",
|
||||
input: &ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{aws.String("snap-1234567890abcdef0")},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "InvalidSnapshotIdFormat",
|
||||
input: &ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{aws.String("invalid-id")},
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidSnapshotIDMalformed,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateDescribeSnapshotsInput(tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateDeleteSnapshotInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *ec2.DeleteSnapshotInput
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "NilInput",
|
||||
input: nil,
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
},
|
||||
{
|
||||
name: "EmptyInput",
|
||||
input: &ec2.DeleteSnapshotInput{},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
},
|
||||
{
|
||||
name: "InvalidSnapshotIdFormat",
|
||||
input: &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: aws.String("invalid-id"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidSnapshotIDMalformed,
|
||||
},
|
||||
{
|
||||
name: "ValidInput",
|
||||
input: &ec2.DeleteSnapshotInput{
|
||||
SnapshotId: aws.String("snap-1234567890abcdef0"),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateDeleteSnapshotInput(tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCopySnapshotInput(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input *ec2.CopySnapshotInput
|
||||
wantErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "NilInput",
|
||||
input: nil,
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidParameterValue,
|
||||
},
|
||||
{
|
||||
name: "EmptyInput",
|
||||
input: &ec2.CopySnapshotInput{},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
},
|
||||
{
|
||||
name: "MissingSourceRegion",
|
||||
input: &ec2.CopySnapshotInput{
|
||||
SourceSnapshotId: aws.String("snap-1234567890abcdef0"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorMissingParameter,
|
||||
},
|
||||
{
|
||||
name: "InvalidSnapshotIdFormat",
|
||||
input: &ec2.CopySnapshotInput{
|
||||
SourceSnapshotId: aws.String("invalid-id"),
|
||||
SourceRegion: aws.String("ap-southeast-2"),
|
||||
},
|
||||
wantErr: true,
|
||||
errMsg: awserrors.ErrorInvalidSnapshotIDMalformed,
|
||||
},
|
||||
{
|
||||
name: "ValidInput",
|
||||
input: &ec2.CopySnapshotInput{
|
||||
SourceSnapshotId: aws.String("snap-1234567890abcdef0"),
|
||||
SourceRegion: aws.String("ap-southeast-2"),
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := ValidateCopySnapshotInput(tt.input)
|
||||
|
||||
if tt.wantErr {
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, tt.errMsg, err.Error())
|
||||
} else {
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package handlers_ec2_snapshot
|
||||
|
||||
import "github.com/aws/aws-sdk-go/service/ec2"
|
||||
|
||||
// SnapshotService defines the interface for EC2 snapshot operations
|
||||
type SnapshotService interface {
|
||||
CreateSnapshot(input *ec2.CreateSnapshotInput) (*ec2.Snapshot, error)
|
||||
DescribeSnapshots(input *ec2.DescribeSnapshotsInput) (*ec2.DescribeSnapshotsOutput, error)
|
||||
DeleteSnapshot(input *ec2.DeleteSnapshotInput) (*ec2.DeleteSnapshotOutput, error)
|
||||
CopySnapshot(input *ec2.CopySnapshotInput) (*ec2.CopySnapshotOutput, error)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
package handlers_ec2_snapshot
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/objectstore"
|
||||
"github.com/mulgadc/viperblock/viperblock"
|
||||
)
|
||||
|
||||
// Ensure SnapshotServiceImpl implements SnapshotService
|
||||
var _ SnapshotService = (*SnapshotServiceImpl)(nil)
|
||||
|
||||
// SnapshotServiceImpl implements SnapshotService with S3-backed storage
|
||||
type SnapshotServiceImpl struct {
|
||||
config *config.Config
|
||||
store objectstore.ObjectStore
|
||||
mutex sync.RWMutex
|
||||
}
|
||||
|
||||
// SnapshotConfig represents snapshot metadata stored in S3
|
||||
type SnapshotConfig struct {
|
||||
SnapshotID string `json:"snapshot_id"`
|
||||
VolumeID string `json:"volume_id"`
|
||||
VolumeSize int64 `json:"volume_size"`
|
||||
State string `json:"state"`
|
||||
Progress string `json:"progress"`
|
||||
StartTime time.Time `json:"start_time"`
|
||||
Description string `json:"description"`
|
||||
Encrypted bool `json:"encrypted"`
|
||||
OwnerID string `json:"owner_id"`
|
||||
AvailabilityZone string `json:"availability_zone"`
|
||||
Tags map[string]string `json:"tags"`
|
||||
}
|
||||
|
||||
// NewSnapshotServiceImpl creates a new snapshot service implementation
|
||||
func NewSnapshotServiceImpl(cfg *config.Config) *SnapshotServiceImpl {
|
||||
store := objectstore.NewS3ObjectStoreFromConfig(
|
||||
cfg.Predastore.Host,
|
||||
cfg.Predastore.Region,
|
||||
cfg.Predastore.AccessKey,
|
||||
cfg.Predastore.SecretKey,
|
||||
)
|
||||
|
||||
return &SnapshotServiceImpl{
|
||||
config: cfg,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// NewSnapshotServiceImplWithStore creates a snapshot service with a custom ObjectStore (for testing)
|
||||
func NewSnapshotServiceImplWithStore(cfg *config.Config, store objectstore.ObjectStore) *SnapshotServiceImpl {
|
||||
return &SnapshotServiceImpl{
|
||||
config: cfg,
|
||||
store: store,
|
||||
}
|
||||
}
|
||||
|
||||
// getSnapshotKey returns the S3 key for storing snapshot config
|
||||
func getSnapshotKey(snapshotID string) string {
|
||||
return fmt.Sprintf("%s/config.json", snapshotID)
|
||||
}
|
||||
|
||||
// getSnapshotConfig retrieves snapshot config from S3
|
||||
func (s *SnapshotServiceImpl) getSnapshotConfig(snapshotID string) (*SnapshotConfig, error) {
|
||||
key := getSnapshotKey(snapshotID)
|
||||
|
||||
result, err := s.store.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(s.config.Predastore.Bucket),
|
||||
Key: aws.String(key),
|
||||
})
|
||||
if err != nil {
|
||||
if objectstore.IsNoSuchKeyError(err) {
|
||||
return nil, errors.New(awserrors.ErrorInvalidSnapshotNotFound)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
defer result.Body.Close()
|
||||
|
||||
var cfg SnapshotConfig
|
||||
if err := json.NewDecoder(result.Body).Decode(&cfg); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &cfg, nil
|
||||
}
|
||||
|
||||
// putSnapshotConfig stores snapshot config to S3
|
||||
func (s *SnapshotServiceImpl) putSnapshotConfig(snapshotID string, cfg *SnapshotConfig) error {
|
||||
key := getSnapshotKey(snapshotID)
|
||||
|
||||
data, err := json.Marshal(cfg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
_, err = s.store.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String(s.config.Predastore.Bucket),
|
||||
Key: aws.String(key),
|
||||
Body: bytes.NewReader(data),
|
||||
ContentType: aws.String("application/json"),
|
||||
})
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
// snapshotConfigToEC2 converts a SnapshotConfig to an EC2 Snapshot response object
|
||||
func snapshotConfigToEC2(cfg *SnapshotConfig) *ec2.Snapshot {
|
||||
snapshot := &ec2.Snapshot{
|
||||
SnapshotId: aws.String(cfg.SnapshotID),
|
||||
VolumeId: aws.String(cfg.VolumeID),
|
||||
VolumeSize: aws.Int64(cfg.VolumeSize),
|
||||
State: aws.String(cfg.State),
|
||||
Progress: aws.String(cfg.Progress),
|
||||
StartTime: aws.Time(cfg.StartTime),
|
||||
Description: aws.String(cfg.Description),
|
||||
Encrypted: aws.Bool(cfg.Encrypted),
|
||||
OwnerId: aws.String(cfg.OwnerID),
|
||||
}
|
||||
|
||||
if len(cfg.Tags) > 0 {
|
||||
tags := make([]*ec2.Tag, 0, len(cfg.Tags))
|
||||
for key, value := range cfg.Tags {
|
||||
tags = append(tags, &ec2.Tag{
|
||||
Key: aws.String(key),
|
||||
Value: aws.String(value),
|
||||
})
|
||||
}
|
||||
snapshot.Tags = tags
|
||||
}
|
||||
|
||||
return snapshot
|
||||
}
|
||||
|
||||
// CreateSnapshot creates a new snapshot from a volume
|
||||
func (s *SnapshotServiceImpl) CreateSnapshot(input *ec2.CreateSnapshotInput) (*ec2.Snapshot, error) {
|
||||
if input == nil || input.VolumeId == nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
volumeID := *input.VolumeId
|
||||
|
||||
slog.Info("CreateSnapshot request", "volumeId", volumeID)
|
||||
|
||||
snapshotID := viperblock.GenerateVolumeID("snap", fmt.Sprintf("snap-%d", time.Now().UnixNano()), s.config.Predastore.Bucket, time.Now().Unix())
|
||||
|
||||
volumeConfigKey := fmt.Sprintf("%s/config.json", volumeID)
|
||||
volumeResult, err := s.store.GetObject(&s3.GetObjectInput{
|
||||
Bucket: aws.String(s.config.Predastore.Bucket),
|
||||
Key: aws.String(volumeConfigKey),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("CreateSnapshot failed to get volume config", "volumeId", volumeID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
defer volumeResult.Body.Close()
|
||||
|
||||
var volumeConfig viperblock.VolumeConfig
|
||||
if err := json.NewDecoder(volumeResult.Body).Decode(&volumeConfig); err != nil {
|
||||
slog.Error("CreateSnapshot failed to decode volume config", "volumeId", volumeID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
|
||||
// Mark as completed immediately -- Hive v1 stores snapshot metadata
|
||||
// pointing to the source volume rather than copying actual block data.
|
||||
snapshotCfg := &SnapshotConfig{
|
||||
SnapshotID: snapshotID,
|
||||
VolumeID: volumeID,
|
||||
VolumeSize: int64(volumeConfig.VolumeMetadata.SizeGiB),
|
||||
State: "completed",
|
||||
Progress: "100%",
|
||||
StartTime: now,
|
||||
Encrypted: volumeConfig.VolumeMetadata.IsEncrypted,
|
||||
OwnerID: s.config.Predastore.AccessKey,
|
||||
AvailabilityZone: volumeConfig.VolumeMetadata.AvailabilityZone,
|
||||
Tags: make(map[string]string),
|
||||
}
|
||||
|
||||
if input.Description != nil {
|
||||
snapshotCfg.Description = *input.Description
|
||||
}
|
||||
|
||||
for _, tagSpec := range input.TagSpecifications {
|
||||
if tagSpec.ResourceType != nil && *tagSpec.ResourceType == "snapshot" {
|
||||
for _, tag := range tagSpec.Tags {
|
||||
if tag.Key != nil && tag.Value != nil {
|
||||
snapshotCfg.Tags[*tag.Key] = *tag.Value
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.putSnapshotConfig(snapshotID, snapshotCfg); err != nil {
|
||||
slog.Error("CreateSnapshot failed to write config", "snapshotId", snapshotID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
slog.Info("CreateSnapshot completed", "snapshotId", snapshotID, "volumeId", volumeID)
|
||||
|
||||
return snapshotConfigToEC2(snapshotCfg), nil
|
||||
}
|
||||
|
||||
// DescribeSnapshots lists snapshots matching the specified criteria
|
||||
func (s *SnapshotServiceImpl) DescribeSnapshots(input *ec2.DescribeSnapshotsInput) (*ec2.DescribeSnapshotsOutput, error) {
|
||||
s.mutex.RLock()
|
||||
defer s.mutex.RUnlock()
|
||||
|
||||
slog.Info("DescribeSnapshots request", "snapshotIds", input.SnapshotIds)
|
||||
|
||||
snapshotIDFilter := make(map[string]bool)
|
||||
for _, id := range input.SnapshotIds {
|
||||
if id != nil {
|
||||
snapshotIDFilter[*id] = true
|
||||
}
|
||||
}
|
||||
|
||||
listResult, err := s.store.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(s.config.Predastore.Bucket),
|
||||
Prefix: aws.String("snap-"),
|
||||
Delimiter: aws.String("/"),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("DescribeSnapshots failed to list objects", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
var snapshots []*ec2.Snapshot
|
||||
for _, prefix := range listResult.CommonPrefixes {
|
||||
if prefix.Prefix == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
snapshotID := strings.TrimSuffix(*prefix.Prefix, "/")
|
||||
|
||||
if len(snapshotIDFilter) > 0 && !snapshotIDFilter[snapshotID] {
|
||||
continue
|
||||
}
|
||||
|
||||
cfg, err := s.getSnapshotConfig(snapshotID)
|
||||
if err != nil {
|
||||
slog.Warn("DescribeSnapshots failed to get config", "snapshotId", snapshotID, "err", err)
|
||||
continue
|
||||
}
|
||||
|
||||
snapshots = append(snapshots, snapshotConfigToEC2(cfg))
|
||||
}
|
||||
|
||||
slog.Info("DescribeSnapshots completed", "count", len(snapshots))
|
||||
|
||||
return &ec2.DescribeSnapshotsOutput{
|
||||
Snapshots: snapshots,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// DeleteSnapshot deletes a snapshot
|
||||
func (s *SnapshotServiceImpl) DeleteSnapshot(input *ec2.DeleteSnapshotInput) (*ec2.DeleteSnapshotOutput, error) {
|
||||
if input == nil || input.SnapshotId == nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
snapshotID := *input.SnapshotId
|
||||
|
||||
slog.Info("DeleteSnapshot request", "snapshotId", snapshotID)
|
||||
|
||||
_, err := s.getSnapshotConfig(snapshotID)
|
||||
if err != nil {
|
||||
slog.Error("DeleteSnapshot snapshot not found", "snapshotId", snapshotID, "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
listResult, err := s.store.ListObjects(&s3.ListObjectsInput{
|
||||
Bucket: aws.String(s.config.Predastore.Bucket),
|
||||
Prefix: aws.String(snapshotID + "/"),
|
||||
})
|
||||
if err != nil {
|
||||
slog.Error("DeleteSnapshot failed to list objects", "snapshotId", snapshotID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
for _, obj := range listResult.Contents {
|
||||
if obj.Key == nil {
|
||||
continue
|
||||
}
|
||||
_, err := s.store.DeleteObject(&s3.DeleteObjectInput{
|
||||
Bucket: aws.String(s.config.Predastore.Bucket),
|
||||
Key: obj.Key,
|
||||
})
|
||||
if err != nil {
|
||||
slog.Warn("DeleteSnapshot failed to delete object", "key", *obj.Key, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.Info("DeleteSnapshot completed", "snapshotId", snapshotID)
|
||||
|
||||
return &ec2.DeleteSnapshotOutput{}, nil
|
||||
}
|
||||
|
||||
// CopySnapshot copies a snapshot (within same region for now)
|
||||
func (s *SnapshotServiceImpl) CopySnapshot(input *ec2.CopySnapshotInput) (*ec2.CopySnapshotOutput, error) {
|
||||
if input == nil || input.SourceSnapshotId == nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
sourceSnapshotID := *input.SourceSnapshotId
|
||||
|
||||
slog.Info("CopySnapshot request", "sourceSnapshotId", sourceSnapshotID)
|
||||
|
||||
sourceCfg, err := s.getSnapshotConfig(sourceSnapshotID)
|
||||
if err != nil {
|
||||
slog.Error("CopySnapshot source snapshot not found", "snapshotId", sourceSnapshotID, "err", err)
|
||||
return nil, err
|
||||
}
|
||||
|
||||
newSnapshotID := viperblock.GenerateVolumeID("snap", fmt.Sprintf("snap-%d", time.Now().UnixNano()), s.config.Predastore.Bucket, time.Now().Unix())
|
||||
|
||||
newCfg := &SnapshotConfig{
|
||||
SnapshotID: newSnapshotID,
|
||||
VolumeID: sourceCfg.VolumeID,
|
||||
VolumeSize: sourceCfg.VolumeSize,
|
||||
State: "completed",
|
||||
Progress: "100%",
|
||||
StartTime: time.Now(),
|
||||
Description: sourceCfg.Description,
|
||||
Encrypted: sourceCfg.Encrypted,
|
||||
OwnerID: sourceCfg.OwnerID,
|
||||
AvailabilityZone: sourceCfg.AvailabilityZone,
|
||||
Tags: make(map[string]string),
|
||||
}
|
||||
|
||||
if input.Description != nil {
|
||||
newCfg.Description = *input.Description
|
||||
}
|
||||
|
||||
for k, v := range sourceCfg.Tags {
|
||||
newCfg.Tags[k] = v
|
||||
}
|
||||
|
||||
if err := s.putSnapshotConfig(newSnapshotID, newCfg); err != nil {
|
||||
slog.Error("CopySnapshot failed to write config", "snapshotId", newSnapshotID, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
slog.Info("CopySnapshot completed", "sourceSnapshotId", sourceSnapshotID, "newSnapshotId", newSnapshotID)
|
||||
|
||||
return &ec2.CopySnapshotOutput{
|
||||
SnapshotId: aws.String(newSnapshotID),
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -0,0 +1,320 @@
|
||||
package handlers_ec2_snapshot
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/aws/aws-sdk-go/service/s3"
|
||||
"github.com/mulgadc/hive/hive/awserrors"
|
||||
"github.com/mulgadc/hive/hive/config"
|
||||
"github.com/mulgadc/hive/hive/objectstore"
|
||||
"github.com/mulgadc/viperblock/viperblock"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// setupTestSnapshotService creates a snapshot service with in-memory storage for testing
|
||||
func setupTestSnapshotService(t *testing.T) (*SnapshotServiceImpl, *objectstore.MemoryObjectStore) {
|
||||
store := objectstore.NewMemoryObjectStore()
|
||||
cfg := &config.Config{
|
||||
Predastore: config.PredastoreConfig{
|
||||
Bucket: "test-bucket",
|
||||
AccessKey: "test-owner-123",
|
||||
},
|
||||
}
|
||||
|
||||
svc := NewSnapshotServiceImplWithStore(cfg, store)
|
||||
return svc, store
|
||||
}
|
||||
|
||||
// createTestVolume creates a test volume in the mock store
|
||||
func createTestVolume(t *testing.T, store *objectstore.MemoryObjectStore, volumeID string, sizeGiB int) {
|
||||
volumeConfig := viperblock.VolumeConfig{
|
||||
VolumeMetadata: viperblock.VolumeMetadata{
|
||||
SizeGiB: uint64(sizeGiB),
|
||||
IsEncrypted: false,
|
||||
AvailabilityZone: "us-east-1a",
|
||||
},
|
||||
}
|
||||
data, err := json.Marshal(volumeConfig)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = store.PutObject(&s3.PutObjectInput{
|
||||
Bucket: aws.String("test-bucket"),
|
||||
Key: aws.String(volumeID + "/config.json"),
|
||||
Body: strings.NewReader(string(data)),
|
||||
ContentType: aws.String("application/json"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
// TestCreateSnapshot tests creating a snapshot from a volume
|
||||
func TestCreateSnapshot(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
// Create a test volume first
|
||||
volumeID := "vol-test123"
|
||||
createTestVolume(t, store, volumeID, 100)
|
||||
|
||||
// Create snapshot
|
||||
result, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String(volumeID),
|
||||
Description: aws.String("Test snapshot"),
|
||||
TagSpecifications: []*ec2.TagSpecification{
|
||||
{
|
||||
ResourceType: aws.String("snapshot"),
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Name"), Value: aws.String("test-snap")},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.True(t, strings.HasPrefix(*result.SnapshotId, "snap-"))
|
||||
assert.Equal(t, volumeID, *result.VolumeId)
|
||||
assert.Equal(t, int64(100), *result.VolumeSize)
|
||||
assert.Equal(t, "completed", *result.State)
|
||||
assert.Equal(t, "100%", *result.Progress)
|
||||
assert.Equal(t, "Test snapshot", *result.Description)
|
||||
|
||||
// Verify tags
|
||||
assert.Len(t, result.Tags, 1)
|
||||
assert.Equal(t, "Name", *result.Tags[0].Key)
|
||||
assert.Equal(t, "test-snap", *result.Tags[0].Value)
|
||||
}
|
||||
|
||||
// TestCreateSnapshot_MissingVolumeId tests creating a snapshot without volume ID
|
||||
func TestCreateSnapshot_MissingVolumeId(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
|
||||
_, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
// TestCreateSnapshot_VolumeNotFound tests creating a snapshot from non-existent volume
|
||||
func TestCreateSnapshot_VolumeNotFound(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
|
||||
_, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-nonexistent"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidVolumeNotFound)
|
||||
}
|
||||
|
||||
// TestDescribeSnapshots tests listing all snapshots
|
||||
func TestDescribeSnapshots(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
// Create test volumes
|
||||
createTestVolume(t, store, "vol-1", 50)
|
||||
createTestVolume(t, store, "vol-2", 100)
|
||||
|
||||
// Create multiple snapshots
|
||||
snap1, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-1"),
|
||||
Description: aws.String("Snapshot 1"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
snap2, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-2"),
|
||||
Description: aws.String("Snapshot 2"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Describe all snapshots
|
||||
result, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Len(t, result.Snapshots, 2)
|
||||
|
||||
// Verify snapshot IDs are present
|
||||
snapshotIDs := make(map[string]bool)
|
||||
for _, snap := range result.Snapshots {
|
||||
snapshotIDs[*snap.SnapshotId] = true
|
||||
}
|
||||
assert.True(t, snapshotIDs[*snap1.SnapshotId])
|
||||
assert.True(t, snapshotIDs[*snap2.SnapshotId])
|
||||
}
|
||||
|
||||
// TestDescribeSnapshots_ByID tests listing specific snapshots by ID
|
||||
func TestDescribeSnapshots_ByID(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
// Create test volume
|
||||
createTestVolume(t, store, "vol-1", 50)
|
||||
|
||||
// Create multiple snapshots
|
||||
snap1, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-1"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-1"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Describe only the first snapshot
|
||||
result, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{snap1.SnapshotId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Len(t, result.Snapshots, 1)
|
||||
assert.Equal(t, *snap1.SnapshotId, *result.Snapshots[0].SnapshotId)
|
||||
}
|
||||
|
||||
// TestDescribeSnapshots_Empty tests listing snapshots when none exist
|
||||
func TestDescribeSnapshots_Empty(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
|
||||
result, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, result)
|
||||
assert.Empty(t, result.Snapshots)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot tests deleting a snapshot
|
||||
func TestDeleteSnapshot(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
// Create test volume and snapshot
|
||||
createTestVolume(t, store, "vol-1", 50)
|
||||
snap, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-1"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify snapshot exists
|
||||
result, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{snap.SnapshotId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result.Snapshots, 1)
|
||||
|
||||
// Delete snapshot
|
||||
_, err = svc.DeleteSnapshot(&ec2.DeleteSnapshotInput{
|
||||
SnapshotId: snap.SnapshotId,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify snapshot is gone
|
||||
result, err = svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{snap.SnapshotId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, result.Snapshots)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_NotFound tests deleting a non-existent snapshot
|
||||
func TestDeleteSnapshot_NotFound(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
|
||||
_, err := svc.DeleteSnapshot(&ec2.DeleteSnapshotInput{
|
||||
SnapshotId: aws.String("snap-nonexistent"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidSnapshotNotFound)
|
||||
}
|
||||
|
||||
// TestDeleteSnapshot_MissingID tests deleting without snapshot ID
|
||||
func TestDeleteSnapshot_MissingID(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
|
||||
_, err := svc.DeleteSnapshot(&ec2.DeleteSnapshotInput{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
// TestCopySnapshot tests copying a snapshot
|
||||
func TestCopySnapshot(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
// Create test volume and snapshot
|
||||
createTestVolume(t, store, "vol-1", 50)
|
||||
snap, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-1"),
|
||||
Description: aws.String("Original snapshot"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Copy snapshot
|
||||
copyResult, err := svc.CopySnapshot(&ec2.CopySnapshotInput{
|
||||
SourceSnapshotId: snap.SnapshotId,
|
||||
Description: aws.String("Copied snapshot"),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, copyResult)
|
||||
assert.True(t, strings.HasPrefix(*copyResult.SnapshotId, "snap-"))
|
||||
assert.NotEqual(t, *snap.SnapshotId, *copyResult.SnapshotId)
|
||||
|
||||
// Verify both snapshots exist
|
||||
result, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{})
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, result.Snapshots, 2)
|
||||
}
|
||||
|
||||
// TestCopySnapshot_NotFound tests copying a non-existent snapshot
|
||||
func TestCopySnapshot_NotFound(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
|
||||
_, err := svc.CopySnapshot(&ec2.CopySnapshotInput{
|
||||
SourceSnapshotId: aws.String("snap-nonexistent"),
|
||||
})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidSnapshotNotFound)
|
||||
}
|
||||
|
||||
// TestCopySnapshot_MissingSourceID tests copying without source snapshot ID
|
||||
func TestCopySnapshot_MissingSourceID(t *testing.T) {
|
||||
svc, _ := setupTestSnapshotService(t)
|
||||
|
||||
_, err := svc.CopySnapshot(&ec2.CopySnapshotInput{})
|
||||
require.Error(t, err)
|
||||
assert.Contains(t, err.Error(), awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
// TestCopySnapshot_PreservesTags tests that tags are copied
|
||||
func TestCopySnapshot_PreservesTags(t *testing.T) {
|
||||
svc, store := setupTestSnapshotService(t)
|
||||
|
||||
// Create test volume and snapshot with tags
|
||||
createTestVolume(t, store, "vol-1", 50)
|
||||
snap, err := svc.CreateSnapshot(&ec2.CreateSnapshotInput{
|
||||
VolumeId: aws.String("vol-1"),
|
||||
TagSpecifications: []*ec2.TagSpecification{
|
||||
{
|
||||
ResourceType: aws.String("snapshot"),
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("Environment"), Value: aws.String("test")},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Copy snapshot
|
||||
copyResult, err := svc.CopySnapshot(&ec2.CopySnapshotInput{
|
||||
SourceSnapshotId: snap.SnapshotId,
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify copied snapshot has tags
|
||||
result, err := svc.DescribeSnapshots(&ec2.DescribeSnapshotsInput{
|
||||
SnapshotIds: []*string{copyResult.SnapshotId},
|
||||
})
|
||||
require.NoError(t, err)
|
||||
require.Len(t, result.Snapshots, 1)
|
||||
assert.Len(t, result.Snapshots[0].Tags, 1)
|
||||
assert.Equal(t, "Environment", *result.Snapshots[0].Tags[0].Key)
|
||||
assert.Equal(t, "test", *result.Snapshots[0].Tags[0].Value)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
package handlers_ec2_snapshot
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/hive/hive/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// NATSSnapshotService handles snapshot operations via NATS messaging
|
||||
type NATSSnapshotService struct {
|
||||
natsConn *nats.Conn
|
||||
}
|
||||
|
||||
// NewNATSSnapshotService creates a new NATS-based snapshot service
|
||||
func NewNATSSnapshotService(conn *nats.Conn) SnapshotService {
|
||||
return &NATSSnapshotService{natsConn: conn}
|
||||
}
|
||||
|
||||
func (s *NATSSnapshotService) CreateSnapshot(input *ec2.CreateSnapshotInput) (*ec2.Snapshot, error) {
|
||||
return utils.NATSRequest[ec2.Snapshot](s.natsConn, "ec2.CreateSnapshot", input, 120*time.Second)
|
||||
}
|
||||
|
||||
func (s *NATSSnapshotService) DescribeSnapshots(input *ec2.DescribeSnapshotsInput) (*ec2.DescribeSnapshotsOutput, error) {
|
||||
return utils.NATSRequest[ec2.DescribeSnapshotsOutput](s.natsConn, "ec2.DescribeSnapshots", input, 30*time.Second)
|
||||
}
|
||||
|
||||
func (s *NATSSnapshotService) DeleteSnapshot(input *ec2.DeleteSnapshotInput) (*ec2.DeleteSnapshotOutput, error) {
|
||||
return utils.NATSRequest[ec2.DeleteSnapshotOutput](s.natsConn, "ec2.DeleteSnapshot", input, 60*time.Second)
|
||||
}
|
||||
|
||||
func (s *NATSSnapshotService) CopySnapshot(input *ec2.CopySnapshotInput) (*ec2.CopySnapshotOutput, error) {
|
||||
return utils.NATSRequest[ec2.CopySnapshotOutput](s.natsConn, "ec2.CopySnapshot", input, 120*time.Second)
|
||||
}
|
||||
Reference in New Issue
Block a user