feat: derive Encrypted from VBState.EncryptionEnabled, drop IsEncrypted reads

VolumeMetadata.IsEncrypted is being removed from viperblock — it was
hardcoded false everywhere in spinifex and misrepresented the per-volume
encryption status to AWS SDK callers. The authoritative flag is now
VBState.EncryptionEnabled, persisted by viperblock at New() when
MasterKey != nil.

- volume CreateVolume drops the dead IsEncrypted struct member and
  surfaces Encrypted=(mkey != nil) on the EC2 response.
- volume getVolumeByID now decodes the full VBState (falling back to a
  wrapper-only blob for legacy pre-encryption volumes) so it can read
  state.EncryptionEnabled directly.
- snapshot CreateSnapshot pulls Encrypted from the source volume's
  VBState.EncryptionEnabled (already loaded one level up).
- image synthesizeRootBlockDeviceMapping takes the cluster encryption
  posture (mkey != nil) since AMI metadata carries no per-image flag;
  cached masterkey load keeps the hot path cheap.
- Test fixtures drop the now-defunct IsEncrypted lines; the volume
  fixture that asserts Encrypted=true switches to a full VBState seed
  with EncryptionEnabled=true.
This commit is contained in:
brynmailer-mdc
2026-06-01 15:13:51 +10:00
committed by Bryn Mailer
parent b5220996e1
commit 557d3e4d59
5 changed files with 66 additions and 18 deletions
+23 -5
View File
@@ -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)
@@ -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"),
@@ -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",
},
},
+25 -9
View File
@@ -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.
@@ -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)