diff --git a/TODO.md b/TODO.md
index 51e8d7fd1..95a8173f4 100644
--- a/TODO.md
+++ b/TODO.md
@@ -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
2. Snapshot detached volume
3. Invalid volume ID (error)
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
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
2. Delete snapshot used by AMI (error)
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
2. Filter by snapshot ID
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
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
2. Missing volume ID (InvalidParameterValue)
3. Volume not found (InvalidVolume.NotFound)
4. Invalid volume ID format (InvalidVolume.Malformed)
5. Snapshot with description
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
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
2. Delete non-existent snapshot (InvalidSnapshot.NotFound)
3. Missing snapshot ID (InvalidParameterValue)
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
2. Filter by snapshot ID
3. Empty snapshot list
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
2. Copy non-existent snapshot (InvalidSnapshot.NotFound)
3. Missing source ID (InvalidParameterValue)
4. Missing source region (MissingParameter)
5. Copy preserves tags
6. Copy with description override | **DONE** |
### EC2 - Tags
diff --git a/hive/daemon/daemon.go b/hive/daemon/daemon.go
index af97bc8b2..aa9a18e9a 100644
--- a/hive/daemon/daemon.go
+++ b/hive/daemon/daemon.go
@@ -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()
diff --git a/hive/daemon/daemon_handlers.go b/hive/daemon/daemon_handlers.go
index 9222296a8..970bae679 100644
--- a/hive/daemon/daemon_handlers.go
+++ b/hive/daemon/daemon_handlers.go
@@ -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)
}
diff --git a/hive/gateway/ec2.go b/hive/gateway/ec2.go
index 37216bcb4..fc79a207e 100644
--- a/hive/gateway/ec2.go
+++ b/hive/gateway/ec2.go
@@ -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 {
diff --git a/hive/gateway/ec2/snapshot/CopySnapshot.go b/hive/gateway/ec2/snapshot/CopySnapshot.go
new file mode 100644
index 000000000..0bb17b269
--- /dev/null
+++ b/hive/gateway/ec2/snapshot/CopySnapshot.go
@@ -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
+}
diff --git a/hive/gateway/ec2/snapshot/CreateSnapshot.go b/hive/gateway/ec2/snapshot/CreateSnapshot.go
new file mode 100644
index 000000000..afbbd550e
--- /dev/null
+++ b/hive/gateway/ec2/snapshot/CreateSnapshot.go
@@ -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
+}
diff --git a/hive/gateway/ec2/snapshot/DeleteSnapshot.go b/hive/gateway/ec2/snapshot/DeleteSnapshot.go
new file mode 100644
index 000000000..62b9d3fbe
--- /dev/null
+++ b/hive/gateway/ec2/snapshot/DeleteSnapshot.go
@@ -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
+}
diff --git a/hive/gateway/ec2/snapshot/DescribeSnapshots.go b/hive/gateway/ec2/snapshot/DescribeSnapshots.go
new file mode 100644
index 000000000..c2890d2f9
--- /dev/null
+++ b/hive/gateway/ec2/snapshot/DescribeSnapshots.go
@@ -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
+}
diff --git a/hive/gateway/ec2/snapshot/Snapshot_test.go b/hive/gateway/ec2/snapshot/Snapshot_test.go
new file mode 100644
index 000000000..097b15af4
--- /dev/null
+++ b/hive/gateway/ec2/snapshot/Snapshot_test.go
@@ -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)
+ }
+ })
+ }
+}
diff --git a/hive/handlers/ec2/snapshot/service.go b/hive/handlers/ec2/snapshot/service.go
new file mode 100644
index 000000000..529ab88b4
--- /dev/null
+++ b/hive/handlers/ec2/snapshot/service.go
@@ -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)
+}
diff --git a/hive/handlers/ec2/snapshot/service_impl.go b/hive/handlers/ec2/snapshot/service_impl.go
new file mode 100644
index 000000000..5138aff9f
--- /dev/null
+++ b/hive/handlers/ec2/snapshot/service_impl.go
@@ -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
+}
+
diff --git a/hive/handlers/ec2/snapshot/service_impl_test.go b/hive/handlers/ec2/snapshot/service_impl_test.go
new file mode 100644
index 000000000..19ce0912f
--- /dev/null
+++ b/hive/handlers/ec2/snapshot/service_impl_test.go
@@ -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)
+}
+
diff --git a/hive/handlers/ec2/snapshot/service_nats.go b/hive/handlers/ec2/snapshot/service_nats.go
new file mode 100644
index 000000000..7e41bb243
--- /dev/null
+++ b/hive/handlers/ec2/snapshot/service_nats.go
@@ -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)
+}