diff --git a/spinifex/handlers/ec2/image/service_impl.go b/spinifex/handlers/ec2/image/service_impl.go index 5f100e6c0..5fd6b4829 100644 --- a/spinifex/handlers/ec2/image/service_impl.go +++ b/spinifex/handlers/ec2/image/service_impl.go @@ -119,6 +119,7 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun } var images []*ec2.Image + encryptedAtRest := s.clusterEncryptionEnabled() // Iterate over CommonPrefixes to find ami-* directories for _, prefix := range result.CommonPrefixes { @@ -267,7 +268,7 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun BootMode: aws.String(amiMeta.BootMode), } - if bdms := synthesizeRootBlockDeviceMapping(amiMeta); bdms != nil { + if bdms := synthesizeRootBlockDeviceMapping(amiMeta, encryptedAtRest); bdms != nil { image.RootDeviceName = aws.String("/dev/sda1") image.BlockDeviceMappings = bdms } @@ -918,7 +919,7 @@ func (s *ImageServiceImpl) DescribeImageAttribute(input *ec2.DescribeImageAttrib case ec2.ImageAttributeNameDescription: output.Description = &ec2.AttributeValue{Value: aws.String(meta.Description)} case ec2.ImageAttributeNameBlockDeviceMapping: - output.BlockDeviceMappings = synthesizeRootBlockDeviceMapping(meta) + output.BlockDeviceMappings = synthesizeRootBlockDeviceMapping(meta, s.clusterEncryptionEnabled()) default: return nil, errors.New(awserrors.ErrorInvalidParameterValue) } @@ -927,9 +928,26 @@ func (s *ImageServiceImpl) DescribeImageAttribute(input *ec2.DescribeImageAttrib return output, nil } +// clusterEncryptionEnabled reports whether the daemon has a viperblock master +// key configured. AMI metadata carries no per-image encryption flag, so block +// device synthesis falls back to this cluster-level posture. +func (s *ImageServiceImpl) clusterEncryptionEnabled() bool { + if s.config == nil { + return false + } + mkey, err := utils.LoadViperblockMasterKey(s.config.Viperblock.EncryptionKeyFile) + if err != nil { + slog.Warn("clusterEncryptionEnabled: failed to load master key, reporting false", "err", err) + return false + } + return mkey != nil +} + // synthesizeRootBlockDeviceMapping returns /dev/sda1 with size+snapshot from -// AMIMetadata, or nil for non-EBS AMIs. -func synthesizeRootBlockDeviceMapping(meta viperblock.AMIMetadata) []*ec2.BlockDeviceMapping { +// AMIMetadata, or nil for non-EBS AMIs. encrypted reflects the cluster-level +// encryption-at-rest posture (master key configured) since AMI metadata does +// not carry a per-image encryption flag of its own. +func synthesizeRootBlockDeviceMapping(meta viperblock.AMIMetadata, encrypted bool) []*ec2.BlockDeviceMapping { if meta.RootDeviceType != "ebs" { return nil } @@ -937,7 +955,7 @@ func synthesizeRootBlockDeviceMapping(meta viperblock.AMIMetadata) []*ec2.BlockD VolumeSize: aws.Int64(utils.SafeUint64ToInt64(meta.VolumeSizeGiB)), VolumeType: aws.String("gp3"), DeleteOnTermination: aws.Bool(true), - Encrypted: aws.Bool(false), + Encrypted: aws.Bool(encrypted), } if meta.SnapshotID != "" { ebs.SnapshotId = aws.String(meta.SnapshotID) diff --git a/spinifex/handlers/ec2/snapshot/service_impl.go b/spinifex/handlers/ec2/snapshot/service_impl.go index 9c0efae00..48faa14c6 100644 --- a/spinifex/handlers/ec2/snapshot/service_impl.go +++ b/spinifex/handlers/ec2/snapshot/service_impl.go @@ -280,7 +280,7 @@ func (s *SnapshotServiceImpl) CreateSnapshot(input *ec2.CreateSnapshotInput, acc State: "completed", Progress: "100%", StartTime: now, - Encrypted: volumeConfig.VolumeMetadata.IsEncrypted, + Encrypted: volumeState.EncryptionEnabled, OwnerID: accountID, AvailabilityZone: volumeConfig.VolumeMetadata.AvailabilityZone, Tags: utils.ExtractTags(input.TagSpecifications, "snapshot"), diff --git a/spinifex/handlers/ec2/snapshot/service_impl_test.go b/spinifex/handlers/ec2/snapshot/service_impl_test.go index c1346254e..501950265 100644 --- a/spinifex/handlers/ec2/snapshot/service_impl_test.go +++ b/spinifex/handlers/ec2/snapshot/service_impl_test.go @@ -43,7 +43,6 @@ func createTestVolume(t *testing.T, store *objectstore.MemoryObjectStore, volume VolumeConfig: viperblock.VolumeConfig{ VolumeMetadata: viperblock.VolumeMetadata{ SizeGiB: uint64(sizeGiB), - IsEncrypted: false, AvailabilityZone: "us-east-1a", }, }, diff --git a/spinifex/handlers/ec2/volume/service_impl.go b/spinifex/handlers/ec2/volume/service_impl.go index 1305fc91e..1160eebe2 100644 --- a/spinifex/handlers/ec2/volume/service_impl.go +++ b/spinifex/handlers/ec2/volume/service_impl.go @@ -161,7 +161,6 @@ func (s *VolumeServiceImpl) CreateVolume(input *ec2.CreateVolumeInput, accountID AvailabilityZone: *input.AvailabilityZone, VolumeType: volumeType, IOPS: iops, - IsEncrypted: false, SnapshotID: snapshotID, }, } @@ -230,7 +229,7 @@ func (s *VolumeServiceImpl) CreateVolume(input *ec2.CreateVolumeInput, accountID AvailabilityZone: input.AvailabilityZone, CreateTime: aws.Time(now), Iops: aws.Int64(int64(iops)), - Encrypted: aws.Bool(false), + Encrypted: aws.Bool(mkey != nil), } if snapshotID != "" { @@ -904,7 +903,7 @@ type volumeResult struct { // getVolumeByID fetches a single volume's config from S3 and builds an EC2 Volume. // Returns the volume and the stored TenantID for account scoping. func (s *VolumeServiceImpl) getVolumeByID(volumeID string) (*volumeResult, error) { - cfg, err := s.GetVolumeConfig(volumeID) + cfg, encryptionEnabled, err := s.getVolumeConfigAndEncryption(volumeID) if err != nil { return nil, err } @@ -937,7 +936,7 @@ func (s *VolumeServiceImpl) getVolumeByID(volumeID string) (*volumeResult, error AvailabilityZone: aws.String(volMeta.AvailabilityZone), CreateTime: aws.Time(volMeta.CreatedAt), VolumeType: aws.String(volumeType), - Encrypted: aws.Bool(volMeta.IsEncrypted), + Encrypted: aws.Bool(encryptionEnabled), } if volMeta.IOPS > 0 { @@ -977,6 +976,15 @@ type volumeConfigWrapper struct { // GetVolumeConfig reads the raw VolumeConfig from S3 for a given volume ID. func (s *VolumeServiceImpl) GetVolumeConfig(volumeID string) (*viperblock.VolumeConfig, error) { + cfg, _, err := s.getVolumeConfigAndEncryption(volumeID) + return cfg, err +} + +// getVolumeConfigAndEncryption reads config.json and surfaces both the +// VolumeConfig and the authoritative VBState.EncryptionEnabled flag. Wrapper- +// only blobs (pre-VBState volumes or freshly-seeded test fixtures) report +// encryptionEnabled=false — they predate encryption-at-rest by construction. +func (s *VolumeServiceImpl) getVolumeConfigAndEncryption(volumeID string) (*viperblock.VolumeConfig, bool, error) { configKey := volumeID + "/config.json" getResult, err := s.store.GetObject(&s3.GetObjectInput{ @@ -985,23 +993,31 @@ func (s *VolumeServiceImpl) GetVolumeConfig(volumeID string) (*viperblock.Volume }) if err != nil { if objectstore.IsNoSuchKeyError(err) { - return nil, errors.New(awserrors.ErrorInvalidVolumeNotFound) + return nil, false, errors.New(awserrors.ErrorInvalidVolumeNotFound) } - return nil, fmt.Errorf("failed to get config: %w", err) + return nil, false, fmt.Errorf("failed to get config: %w", err) } defer getResult.Body.Close() body, err := io.ReadAll(getResult.Body) if err != nil { - return nil, fmt.Errorf("failed to read config body: %w", err) + return nil, false, fmt.Errorf("failed to read config body: %w", err) + } + + // Try full VBState first (matches mergeVolumeConfig's careful decode + // pattern). A populated BlockSize is the marker that the blob is a full + // state rather than a wrapper-only fallback. + var state viperblock.VBState + if decodeErr := json.NewDecoder(bytes.NewReader(body)).Decode(&state); decodeErr == nil && state.BlockSize != 0 { + return &state.VolumeConfig, state.EncryptionEnabled, nil } var wrapper volumeConfigWrapper if err := json.Unmarshal(body, &wrapper); err != nil { - return nil, fmt.Errorf("failed to unmarshal config: %w", err) + return nil, false, fmt.Errorf("failed to unmarshal config: %w", err) } - return &wrapper.VolumeConfig, nil + return &wrapper.VolumeConfig, false, nil } // putVolumeConfig writes a VolumeConfig back to S3 as config.json. diff --git a/spinifex/handlers/ec2/volume/service_impl_test.go b/spinifex/handlers/ec2/volume/service_impl_test.go index 680c8ad5e..fb6827309 100644 --- a/spinifex/handlers/ec2/volume/service_impl_test.go +++ b/spinifex/handlers/ec2/volume/service_impl_test.go @@ -603,14 +603,29 @@ func TestGetVolumeByID_FullMetadata(t *testing.T) { VolumeType: "gp3", IOPS: 5000, SnapshotID: "snap-abc", - IsEncrypted: true, AttachedInstance: "i-12345", DeviceName: "/dev/nbd0", DeleteOnTermination: true, AttachedAt: now, Tags: map[string]string{"Name": "test-vol", "env": "dev"}, } - createVolumeInStoreWithMeta(t, store, "vol-full", meta) + // Seed as a full VBState with EncryptionEnabled=true so getVolumeByID + // reports Encrypted via the authoritative VBState.EncryptionEnabled path. + state := viperblock.VBState{ + VolumeName: "vol-full", + VolumeSize: meta.SizeGiB * 1024 * 1024 * 1024, + BlockSize: 4096, + EncryptionEnabled: true, + VolumeConfig: viperblock.VolumeConfig{VolumeMetadata: meta}, + } + data, err := json.Marshal(state) + require.NoError(t, err) + _, err = store.PutObject(&s3.PutObjectInput{ + Bucket: aws.String("test-bucket"), + Key: aws.String("vol-full/config.json"), + Body: strings.NewReader(string(data)), + }) + require.NoError(t, err) result, err := svc.getVolumeByID("vol-full") require.NoError(t, err)