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) +}