feat: snapshots (#30)

This commit is contained in:
Julian Sommer
2026-02-10 09:45:53 +11:00
committed by GitHub
parent 2977b03cef
commit 513fd07ced
13 changed files with 1178 additions and 5 deletions
+5 -5
View File
@@ -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
+7
View File
@@ -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()
+16
View File
@@ -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
View File
@@ -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 {
+49
View File
@@ -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
}
+226
View File
@@ -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)
}
})
}
}
+11
View File
@@ -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)
}
+362
View File
@@ -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)
}