Merge commit '0a7832440504442f685e85048d545848a8f2b8f6' into feat/iso-installer

This commit is contained in:
tomnewton-mulga
2026-04-23 09:07:08 +10:00
21 changed files with 3367 additions and 152 deletions
+6 -6
View File
@@ -158,13 +158,13 @@ file, deferred to a later phase.
|---------|-------------------|---------------|---------------|-------------|------------|--------|
| `describe-images` | `--image-ids` (format validation only), `--owners` (self, account ID, alias), `--filters` (name, state, architecture, image-id, is-public, owner-id, description, image-type, tag:\*) | `--executable-users`, `--include-deprecated`, `--include-disabled`, `--max-results`, `--next-token`, `--dry-run` | None | NATS `ec2.DescribeImages` → daemon reads AMI metadata from Predastore S3 buckets (ami-*) → filters by ImageIds, Owners, and Filters → returns image list with state, architecture, block device mappings | 1. List all images<br>2. Filter by image ID<br>3. Filter by owner (self/amazon)<br>4. Non-existent AMI returns empty<br>5. Verify metadata fields (architecture, state, rootDeviceName)<br>6. Filter by name wildcard<br>7. Unknown filter returns InvalidParameterValue | **DONE** |
| `create-image` | `--instance-id`, `--name`, `--description`, `--tag-specifications` | `--no-reboot`, `--block-device-mappings`, `--dry-run` | Instance must exist (running or stopped) | Gateway validates input → NATS `ec2.{instanceId}.CreateImage` (per-instance topic) → daemon extracts root volume → snapshots via `ebs.snapshot` NATS (running) or offline S3 copy (stopped) → creates AMI metadata in S3 (`{amiId}/config.json`) → stores tags → returns ami-ID. Duplicate AMI name validation enforced. | 1. Create image from running instance<br>2. Create image from stopped instance<br>3. Invalid instance ID (error)<br>4. Duplicate AMI name (error)<br>5. Verify new AMI appears in describe-images<br>6. Launch new instance from created AMI | **DONE** |
| `register-image` | — | `--name`, `--description`, `--architecture`, `--root-device-name`, `--virtualization-type`, `--block-device-mappings`, `--boot-mode`, `--ena-support`, `--sriov-net-support`, `--tpm-support`, `--imds-support` | Image data must exist in S3 | Create AMI metadata entry in Predastore → assign ami-ID → set state to available → return ami-ID | 1. Register with valid metadata<br>2. Missing required name (error)<br>3. Verify registered image in describe-images | **NOT STARTED** |
| `deregister-image` | — | `--image-id`, `--dry-run` | AMI must exist, no instances using it | Mark AMI metadata as deregistered in S3 → AMI no longer available for new launches → existing instances unaffected | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (error)<br>3. Verify deregistered AMI not in describe-images<br>4. Existing instances from AMI still run | **NOT STARTED** |
| `copy-image` | — | `--source-image-id`, `--source-region`, `--name`, `--description`, `--encrypted`, `--kms-key-id`, `--client-token`, `--copy-image-tags`, `--tag-specifications` | Source AMI must exist | Copy AMI data between regions/nodes in Predastore → create new AMI metadata with new ami-ID → return new ami-ID | 1. Copy image within same region<br>2. Copy non-existent image (error)<br>3. Verify copied image independent of source | **NOT STARTED** |
| `register-image` | `--name`, `--description`, `--architecture` (x86_64/arm64/i386), `--root-device-name`, `--virtualization-type` (hvm only), `--block-device-mappings` (root with `Ebs.SnapshotId`+optional `VolumeSize`), `--tag-specifications` | `--billing-products`, `--uefi-data` | Backing snapshot must exist in Predastore (`{snapshotId}/metadata.json`); caller must own snapshot or it must be system-owned | Gateway validates name length (3–128), `snap-` prefix, architecture/virtualization values → NATS `ec2.RegisterImage` → daemon checks AMI name uniqueness, reads snapshot metadata, verifies snapshot ownership, builds `viperblock.AMIMetadata` (defaults: `Architecture=x86_64`, `Virtualization=hvm`, `PlatformDetails=Linux/UNIX`, `RootDeviceType=ebs`), writes `{amiId}/config.json`. Pointer-only — never touches block data. `BootMode`, `KernelId`, `RamdiskId`, `TpmSupport`, `ImdsSupport`, `EnaSupport`, `SriovNetSupport`, `ImageLocation`, `paravirtual` virtualization rejected with `InvalidParameterValue`. `VolumeSize` smaller than snapshot rejected. | 1. Register with valid snapshot<br>2. Missing name/snapshot (error)<br>3. Duplicate AMI name (`InvalidAMIName.Duplicate`)<br>4. Snapshot not found (`InvalidSnapshot.NotFound`)<br>5. Cross-account snapshot (`UnauthorizedOperation`)<br>6. Tags from `TagSpecifications` persisted<br>7. Verify registered image in describe-images | **DONE** |
| `deregister-image` | `--image-id` | `--dry-run` | AMI must exist; caller must own it (system AMIs immutable via this API) | Gateway validates `ami-` prefix → NATS `ec2.DeregisterImage` → daemon hard-deletes `{amiId}/config.json` from Predastore. Backing snapshot is left intact (matches AWS — operators run `delete-snapshot` separately to reclaim block storage). Cross-account/system-AMI mutations rejected with `UnauthorizedOperation`. Re-deregister returns `InvalidAMIID.NotFound` (no tombstone). | 1. Deregister existing AMI<br>2. Deregister non-existent AMI (`InvalidAMIID.NotFound`)<br>3. Re-deregister already-deleted AMI (`InvalidAMIID.NotFound`)<br>4. Cross-account AMI (`UnauthorizedOperation`)<br>5. System AMI (`UnauthorizedOperation`)<br>6. Verify deregistered AMI not in describe-images<br>7. Backing snapshot untouched | **DONE** |
| `copy-image` | `--source-image-id`, `--source-region` (must equal gateway region), `--name`, `--description`, `--client-token` (accepted, not honoured), `--copy-image-tags`, `--tag-specifications` (image only) | `--encrypted`, `--kms-key-id`, `--destination-outpost-arn`, `--dry-run` | Source AMI must exist and be owned by caller OR be a system AMI; backing snapshot must still exist | Gateway validates `ami-` prefix, name length (3–128), same-region — cross-region / `Encrypted` / `KmsKeyId` / `DestinationOutpostArn` rejected with `InvalidParameterValue`. NATS `ec2.CopyImage` → daemon checks name uniqueness, reads source `ami-xxx/config.json`, visibility-filters cross-account sources as `InvalidAMIID.NotFound`, reads source `{snap-xxx}/metadata.json` (missing or empty SnapshotID → `InvalidAMIID.NotFound`), writes new `snap-yyy/metadata.json` that inherits source `VolumeID` (no block copy), writes new `ami-yyy/config.json` owned by caller. `Description` inherits from source when unset. Tag merge: `CopyImageTags=true` seeds tags from source, explicit image-resource `TagSpecifications` override colliding keys and append new ones; non-image tag specs ignored. | 1. Same-region copy produces new `ami-`/`snap-` pair with distinct IDs<br>2. New snap shares source `VolumeID` (metadata-only)<br>3. New AMI owned by caller, source untouched<br>4. System AMI copied into caller's account<br>5. Cross-account source (`InvalidAMIID.NotFound`)<br>6. Missing source AMI (`InvalidAMIID.NotFound`)<br>7. Orphaned source (missing/empty `SnapshotID`) → `InvalidAMIID.NotFound`<br>8. Duplicate name (`InvalidAMIName.Duplicate`)<br>9. Cross-region / `Encrypted` / `KmsKeyId` rejected (`InvalidParameterValue`)<br>10. `CopyImageTags` true/false tag inheritance semantics | **DONE** |
| `import-image` | — | `--disk-containers` (Format, Url/S3Bucket+S3Key), `--description`, `--architecture`, `--platform` | S3 bucket with disk image | Download disk image from S3 → convert format (VMDK/VHD/RAW→QCOW2) → create viperblock volume → register as AMI | 1. Import QCOW2 image<br>2. Import RAW image<br>3. Invalid format (error)<br>4. Verify imported image launchable | **NOT STARTED** |
| `describe-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Read specific attribute from AMI metadata in S3 → return attribute value | 1. Get description attribute<br>2. Get launch permission<br>3. Invalid attribute name (error) | **NOT STARTED** |
| `modify-image-attribute` | — | `--image-id`, `--launch-permission`, `--description`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--dry-run` | AMI must exist | Update specific attribute in AMI metadata → persist to S3 | 1. Add launch permission<br>2. Modify description<br>3. Invalid AMI (error) | **NOT STARTED** |
| `reset-image-attribute` | — | `--image-id`, `--attribute`, `--dry-run` | AMI must exist | Reset attribute to default value in AMI metadata | 1. Reset launch permission to owner-only<br>2. Invalid AMI (error) | **NOT STARTED** |
| `describe-image-attribute` | `--image-id`, `--attribute` (`description`, `blockDeviceMapping` only) | `--dry-run`, `--attribute` for `launchPermission`/`bootMode`/`kernel`/`ramdisk`/`sriovNetSupport`/`productCodes`/`tpmSupport`/`uefiData`/`imdsSupport`/`lastLaunchedTime`/`deregistrationProtection` | AMI must exist and be owned by caller OR be a system AMI | Gateway validates `ami-` prefix + allowlisted attribute → NATS `ec2.DescribeImageAttribute` → daemon reads `ami-xxx/config.json`, hides cross-account reads as `InvalidAMIID.NotFound` (matches `DescribeImages`), returns `description` from stored metadata or synthesises a single root `blockDeviceMapping` from `AMIMetadata` (same shape as `DescribeImages`). Unsupported attributes rejected with `InvalidParameterValue` — notably `launchPermission` is deferred (breaks Terraform's `aws_ami` refresh; multi-account AMI sharing needs a separate design). | 1. Read `description`<br>2. Read synthesised `blockDeviceMapping`<br>3. AMI not found (`InvalidAMIID.NotFound`)<br>4. Cross-account AMI hidden (`InvalidAMIID.NotFound`)<br>5. System AMI readable by any caller<br>6. Unsupported attributes (`launchPermission`, `bootMode`, …) rejected with `InvalidParameterValue` | **DONE** |
| `modify-image-attribute` | `--image-id`, `--description` (top-level `Value=…` form), `--attribute description --value …` (structured form) | `--launch-permission`, `--imds-support`, `--operation-type`, `--user-ids`, `--user-groups`, `--organization-arns`, `--product-codes`, `--dry-run`, `--attribute` for anything other than `description` | AMI must exist and be owned by caller (system AMIs immutable via this API) | Gateway validates `ami-` prefix, rejects `LaunchPermission`/`ImdsSupport`/`ProductCodes`/org+user-id flags with `InvalidParameterValue`, and normalises the overloaded input shape (top-level `Description` → `Attribute=description`+`Value=…`). Both forms set together → `InvalidParameterCombination`; neither → `MissingParameter`. NATS `ec2.ModifyImageAttribute` → daemon loads AMI, runs ownership check (cross-account → `UnauthorizedOperation`, system AMIs also rejected), writes `AMIMetadata.Description` back to S3. Empty `Value` clears the description. | 1. Modify description (top-level form)<br>2. Modify description (structured form)<br>3. Clear description with empty value<br>4. Both top-level + structured set (`InvalidParameterCombination`)<br>5. Cross-account / system AMI (`UnauthorizedOperation`)<br>6. AMI not found (`InvalidAMIID.NotFound`)<br>7. `LaunchPermission` / `ImdsSupport` / unsupported `Attribute` rejected (`InvalidParameterValue`)<br>8. Round-trips via `describe-image-attribute` | **DONE** |
| `reset-image-attribute` | `--image-id`, `--attribute description` | `--attribute launchPermission`, `--dry-run` | AMI must exist and be owned by caller | Gateway validates `ami-` prefix; only `description` is accepted (`launchPermission` — AWS's default reset target — is out of scope). NATS `ec2.ResetImageAttribute` → daemon loads AMI, ownership check, clears `AMIMetadata.Description` to empty string, persists. | 1. Reset description to empty<br>2. Cross-account / system AMI (`UnauthorizedOperation`)<br>3. AMI not found (`InvalidAMIID.NotFound`)<br>4. `launchPermission` / other attributes rejected (`InvalidParameterValue`)<br>5. Round-trips via `describe-image-attribute` | **DONE** |
### EC2 - Volume (EBS) Management
+6
View File
@@ -401,6 +401,12 @@ func (d *Daemon) subscribeAll() error {
{"ec2.ImportKeyPair", d.handleEC2ImportKeyPair, "spinifex-workers"},
{"ec2.DescribeImages", d.handleEC2DescribeImages, "spinifex-workers"},
{"ec2.CreateImage", d.handleEC2CreateImage, ""},
{"ec2.DeregisterImage", d.handleEC2DeregisterImage, "spinifex-workers"},
{"ec2.RegisterImage", d.handleEC2RegisterImage, "spinifex-workers"},
{"ec2.CopyImage", d.handleEC2CopyImage, "spinifex-workers"},
{"ec2.DescribeImageAttribute", d.handleEC2DescribeImageAttribute, "spinifex-workers"},
{"ec2.ModifyImageAttribute", d.handleEC2ModifyImageAttribute, "spinifex-workers"},
{"ec2.ResetImageAttribute", d.handleEC2ResetImageAttribute, "spinifex-workers"},
{"ec2.CreateVolume", d.handleEC2CreateVolume, "spinifex-workers"},
{"ec2.DescribeVolumes", d.handleEC2DescribeVolumes, "spinifex-workers"},
{"ec2.ModifyVolume", d.handleEC2ModifyVolume, "spinifex-workers"},
+24
View File
@@ -15,6 +15,30 @@ func (d *Daemon) handleEC2DescribeImages(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.DescribeImages)
}
func (d *Daemon) handleEC2DeregisterImage(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.DeregisterImage)
}
func (d *Daemon) handleEC2RegisterImage(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.RegisterImage)
}
func (d *Daemon) handleEC2CopyImage(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.CopyImage)
}
func (d *Daemon) handleEC2DescribeImageAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.DescribeImageAttribute)
}
func (d *Daemon) handleEC2ModifyImageAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.ModifyImageAttribute)
}
func (d *Daemon) handleEC2ResetImageAttribute(msg *nats.Msg) {
handleNATSRequest(msg, d.imageService.ResetImageAttribute)
}
// handleEC2CreateImage is a stateful handler that extracts instance context
// (root volume ID, source AMI, running state) before delegating to the image service.
func (d *Daemon) handleEC2CreateImage(msg *nats.Msg) {
+18
View File
@@ -114,6 +114,24 @@ var ec2Actions = map[string]EC2Handler{
"CreateImage": ec2Handler(func(input *ec2.CreateImageInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_image.CreateImage(input, gw.NATSConn, gw.DiscoverActiveNodes(), accountID)
}),
"DeregisterImage": ec2Handler(func(input *ec2.DeregisterImageInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_image.DeregisterImage(input, gw.NATSConn, accountID)
}),
"RegisterImage": ec2Handler(func(input *ec2.RegisterImageInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_image.RegisterImage(input, gw.NATSConn, accountID)
}),
"CopyImage": ec2Handler(func(input *ec2.CopyImageInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_image.CopyImage(input, gw.NATSConn, gw.Region, accountID)
}),
"DescribeImageAttribute": ec2Handler(func(input *ec2.DescribeImageAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_image.DescribeImageAttribute(input, gw.NATSConn, accountID)
}),
"ModifyImageAttribute": ec2Handler(func(input *ec2.ModifyImageAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_image.ModifyImageAttribute(input, gw.NATSConn, accountID)
}),
"ResetImageAttribute": ec2Handler(func(input *ec2.ResetImageAttributeInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_image.ResetImageAttribute(input, gw.NATSConn, accountID)
}),
"DescribeRegions": ec2Handler(func(input *ec2.DescribeRegionsInput, gw *GatewayConfig, accountID string) (any, error) {
return gateway_ec2_zone.DescribeRegions(input, gw.Region)
}),
+69
View File
@@ -0,0 +1,69 @@
package gateway_ec2_image
import (
"errors"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
"github.com/nats-io/nats.go"
)
// ValidateCopyImageInput validates the input parameters for CopyImage.
// Copy is single-region and metadata-only; cross-region, encryption, and
// Outposts are rejected here. ClientToken is accepted but not honoured.
func ValidateCopyImageInput(input *ec2.CopyImageInput, gwRegion string) error {
if input == nil {
return errors.New(awserrors.ErrorMissingParameter)
}
if input.Name == nil || *input.Name == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if n := len(*input.Name); n < 3 || n > 128 {
return errors.New(awserrors.ErrorInvalidAMINameMalformed)
}
if input.SourceImageId == nil || *input.SourceImageId == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !strings.HasPrefix(*input.SourceImageId, "ami-") {
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
}
if input.SourceRegion == nil || *input.SourceRegion == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if *input.SourceRegion != gwRegion {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.Encrypted != nil && *input.Encrypted {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.KmsKeyId != nil && *input.KmsKeyId != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.DestinationOutpostArn != nil && *input.DestinationOutpostArn != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
return nil
}
// CopyImage handles the EC2 CopyImage API call.
func CopyImage(input *ec2.CopyImageInput, natsConn *nats.Conn, gwRegion, accountID string) (ec2.CopyImageOutput, error) {
var output ec2.CopyImageOutput
if err := ValidateCopyImageInput(input, gwRegion); err != nil {
return output, err
}
svc := handlers_ec2_image.NewNATSImageService(natsConn, 0)
result, err := svc.CopyImage(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,200 @@
package gateway_ec2_image
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
)
const testCopyImageRegion = "ap-southeast-2"
func validCopyImageInput() *ec2.CopyImageInput {
return &ec2.CopyImageInput{
Name: aws.String("copied-ami"),
SourceImageId: aws.String("ami-1234567890abcdef0"),
SourceRegion: aws.String(testCopyImageRegion),
}
}
func TestValidateCopyImageInput(t *testing.T) {
tests := []struct {
name string
mutate func(*ec2.CopyImageInput)
input *ec2.CopyImageInput
wantErr bool
errMsg string
}{
{
name: "NilInput",
input: nil,
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MissingName",
mutate: func(i *ec2.CopyImageInput) { i.Name = nil },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "EmptyName",
mutate: func(i *ec2.CopyImageInput) { i.Name = aws.String("") },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "ShortName",
mutate: func(i *ec2.CopyImageInput) { i.Name = aws.String("ab") },
wantErr: true,
errMsg: awserrors.ErrorInvalidAMINameMalformed,
},
{
name: "LongName",
mutate: func(i *ec2.CopyImageInput) {
name := make([]byte, 129)
for j := range name {
name[j] = 'a'
}
i.Name = aws.String(string(name))
},
wantErr: true,
errMsg: awserrors.ErrorInvalidAMINameMalformed,
},
{
name: "MissingSourceImageId",
mutate: func(i *ec2.CopyImageInput) { i.SourceImageId = nil },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "EmptySourceImageId",
mutate: func(i *ec2.CopyImageInput) { i.SourceImageId = aws.String("") },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MalformedSourceImageId",
mutate: func(i *ec2.CopyImageInput) { i.SourceImageId = aws.String("not-an-ami") },
wantErr: true,
errMsg: awserrors.ErrorInvalidAMIIDMalformed,
},
{
name: "MissingSourceRegion",
mutate: func(i *ec2.CopyImageInput) { i.SourceRegion = nil },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "EmptySourceRegion",
mutate: func(i *ec2.CopyImageInput) { i.SourceRegion = aws.String("") },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "CrossRegionRejected",
mutate: func(i *ec2.CopyImageInput) { i.SourceRegion = aws.String("us-east-1") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "EncryptedRejected",
mutate: func(i *ec2.CopyImageInput) { i.Encrypted = aws.Bool(true) },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "KmsKeyIdRejected",
mutate: func(i *ec2.CopyImageInput) { i.KmsKeyId = aws.String("alias/some-key") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "DestinationOutpostArnRejected",
mutate: func(i *ec2.CopyImageInput) {
i.DestinationOutpostArn = aws.String("arn:aws:outposts:us-east-1:123:outpost/op-abc")
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ValidMinimal",
wantErr: false,
},
{
name: "ValidWithDescriptionAndClientToken",
mutate: func(i *ec2.CopyImageInput) {
i.Description = aws.String("renamed clone")
i.ClientToken = aws.String("idempotency-token-ignored")
},
wantErr: false,
},
{
name: "ValidWithCopyImageTagsTrue",
mutate: func(i *ec2.CopyImageInput) {
i.CopyImageTags = aws.Bool(true)
},
wantErr: false,
},
{
name: "ValidWithCopyImageTagsFalse",
mutate: func(i *ec2.CopyImageInput) {
i.CopyImageTags = aws.Bool(false)
},
wantErr: false,
},
{
name: "ValidWithTagSpecifications",
mutate: func(i *ec2.CopyImageInput) {
i.TagSpecifications = []*ec2.TagSpecification{
{
ResourceType: aws.String("image"),
Tags: []*ec2.Tag{
{Key: aws.String("Env"), Value: aws.String("prod")},
},
},
}
},
wantErr: false,
},
{
name: "EncryptedFalseAllowed",
mutate: func(i *ec2.CopyImageInput) {
i.Encrypted = aws.Bool(false)
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var input *ec2.CopyImageInput
if tt.input == nil && tt.name != "NilInput" {
input = validCopyImageInput()
} else {
input = tt.input
}
if tt.mutate != nil {
tt.mutate(input)
}
err := ValidateCopyImageInput(input, testCopyImageRegion)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
func TestCopyImage_GatewayValidationFailureReturnsEarly(t *testing.T) {
// nil natsConn is fine: validation rejects the input before any NATS
// round-trip is attempted, so we exercise the early-exit path.
_, err := CopyImage(&ec2.CopyImageInput{}, nil, testCopyImageRegion, "000000000001")
assert.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
@@ -0,0 +1,36 @@
package gateway_ec2_image
import (
"errors"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
"github.com/nats-io/nats.go"
)
func ValidateDeregisterImageInput(input *ec2.DeregisterImageInput) error {
if input == nil || input.ImageId == nil || *input.ImageId == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !strings.HasPrefix(*input.ImageId, "ami-") {
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
}
return nil
}
func DeregisterImage(input *ec2.DeregisterImageInput, natsConn *nats.Conn, accountID string) (ec2.DeregisterImageOutput, error) {
var output ec2.DeregisterImageOutput
if err := ValidateDeregisterImageInput(input); err != nil {
return output, err
}
svc := handlers_ec2_image.NewNATSImageService(natsConn, 0)
result, err := svc.DeregisterImage(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,67 @@
package gateway_ec2_image
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
)
func TestValidateDeregisterImageInput(t *testing.T) {
tests := []struct {
name string
input *ec2.DeregisterImageInput
wantErr bool
errMsg string
}{
{
name: "NilInput",
input: nil,
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "EmptyInput",
input: &ec2.DeregisterImageInput{},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "EmptyImageId",
input: &ec2.DeregisterImageInput{
ImageId: aws.String(""),
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MalformedImageId",
input: &ec2.DeregisterImageInput{
ImageId: aws.String("not-an-ami"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidAMIIDMalformed,
},
{
name: "ValidImageId",
input: &ec2.DeregisterImageInput{
ImageId: aws.String("ami-1234567890abcdef0"),
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateDeregisterImageInput(tt.input)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
@@ -0,0 +1,53 @@
package gateway_ec2_image
import (
"errors"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
"github.com/nats-io/nats.go"
)
// supportedImageAttributes lists the AMI attributes spinifex exposes.
// description is modifiable; blockDeviceMapping is synthesised read-only from
// AMIMetadata. Any other attribute is rejected rather than returning empty.
var supportedImageAttributes = map[string]bool{
ec2.ImageAttributeNameDescription: true,
ec2.ImageAttributeNameBlockDeviceMapping: true,
}
func ValidateDescribeImageAttributeInput(input *ec2.DescribeImageAttributeInput) error {
if input == nil {
return errors.New(awserrors.ErrorMissingParameter)
}
if input.ImageId == nil || *input.ImageId == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !strings.HasPrefix(*input.ImageId, "ami-") {
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
}
if input.Attribute == nil || *input.Attribute == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !supportedImageAttributes[*input.Attribute] {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
return nil
}
func DescribeImageAttribute(input *ec2.DescribeImageAttributeInput, natsConn *nats.Conn, accountID string) (ec2.DescribeImageAttributeOutput, error) {
var output ec2.DescribeImageAttributeOutput
if err := ValidateDescribeImageAttributeInput(input); err != nil {
return output, err
}
svc := handlers_ec2_image.NewNATSImageService(natsConn, 0)
result, err := svc.DescribeImageAttribute(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,111 @@
package gateway_ec2_image
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
)
func TestValidateDescribeImageAttributeInput(t *testing.T) {
tests := []struct {
name string
input *ec2.DescribeImageAttributeInput
wantErr bool
errMsg string
}{
{
name: "NilInput",
input: nil,
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "EmptyInput",
input: &ec2.DescribeImageAttributeInput{},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MissingAttribute",
input: &ec2.DescribeImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MalformedImageId",
input: &ec2.DescribeImageAttributeInput{
ImageId: aws.String("not-an-ami"),
Attribute: aws.String("description"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidAMIIDMalformed,
},
{
name: "UnsupportedAttribute_LaunchPermission",
input: &ec2.DescribeImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("launchPermission"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "UnsupportedAttribute_BootMode",
input: &ec2.DescribeImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("bootMode"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "UnsupportedAttribute_Kernel",
input: &ec2.DescribeImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("kernel"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ValidDescription",
input: &ec2.DescribeImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("description"),
},
wantErr: false,
},
{
name: "ValidBlockDeviceMapping",
input: &ec2.DescribeImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("blockDeviceMapping"),
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateDescribeImageAttributeInput(tt.input)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
func TestDescribeImageAttribute_GatewayValidationFailureReturnsEarly(t *testing.T) {
// nil natsConn is fine: validation rejects before any NATS round-trip.
_, err := DescribeImageAttribute(&ec2.DescribeImageAttributeInput{}, nil, "000000000001")
assert.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
@@ -0,0 +1,87 @@
package gateway_ec2_image
import (
"errors"
"strings"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
"github.com/nats-io/nats.go"
)
// ValidateModifyImageAttributeInput validates and normalises the two AWS
// shapes for modifying description (top-level `--description Value=…` and
// structured `--attribute description --value …`) into Attribute+Value.
// Unsupported top-level shortcuts (launchPermission, imdsSupport, productCodes)
// are rejected rather than silently accepted.
func ValidateModifyImageAttributeInput(input *ec2.ModifyImageAttributeInput) error {
if input == nil {
return errors.New(awserrors.ErrorMissingParameter)
}
if input.ImageId == nil || *input.ImageId == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !strings.HasPrefix(*input.ImageId, "ami-") {
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
}
if input.LaunchPermission != nil {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.ImdsSupport != nil {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if len(input.ProductCodes) > 0 {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if len(input.UserIds) > 0 || len(input.UserGroups) > 0 ||
len(input.OrganizationArns) > 0 || len(input.OrganizationalUnitArns) > 0 ||
(input.OperationType != nil && *input.OperationType != "") {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
hasTopLevelDescription := input.Description != nil
// Treat any set Value (even without Attribute) as structured shape so it
// isn't silently discarded by the top-level branch.
hasStructured := (input.Attribute != nil && *input.Attribute != "") || input.Value != nil
switch {
case hasTopLevelDescription && hasStructured:
return errors.New(awserrors.ErrorInvalidParameterCombination)
case hasTopLevelDescription:
value := ""
if input.Description.Value != nil {
value = *input.Description.Value
}
input.Attribute = aws.String(ec2.ImageAttributeNameDescription)
input.Value = aws.String(value)
case hasStructured:
if input.Attribute == nil || *input.Attribute == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if *input.Attribute != ec2.ImageAttributeNameDescription {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
default:
return errors.New(awserrors.ErrorMissingParameter)
}
return nil
}
func ModifyImageAttribute(input *ec2.ModifyImageAttributeInput, natsConn *nats.Conn, accountID string) (ec2.ModifyImageAttributeOutput, error) {
var output ec2.ModifyImageAttributeOutput
if err := ValidateModifyImageAttributeInput(input); err != nil {
return output, err
}
svc := handlers_ec2_image.NewNATSImageService(natsConn, 0)
result, err := svc.ModifyImageAttribute(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,177 @@
package gateway_ec2_image
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestValidateModifyImageAttributeInput(t *testing.T) {
tests := []struct {
name string
input *ec2.ModifyImageAttributeInput
wantErr bool
errMsg string
}{
{
name: "NilInput",
input: nil,
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MissingImageId",
input: &ec2.ModifyImageAttributeInput{
Description: &ec2.AttributeValue{Value: aws.String("hi")},
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MalformedImageId",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("not-an-ami"),
Description: &ec2.AttributeValue{Value: aws.String("hi")},
},
wantErr: true,
errMsg: awserrors.ErrorInvalidAMIIDMalformed,
},
{
name: "NoAttributeSet",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "BothDescriptionAndAttributeSet",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Description: &ec2.AttributeValue{Value: aws.String("hi")},
Attribute: aws.String("description"),
Value: aws.String("hi"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterCombination,
},
{
name: "LaunchPermissionRejected",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
LaunchPermission: &ec2.LaunchPermissionModifications{},
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ImdsSupportRejected",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
ImdsSupport: &ec2.AttributeValue{Value: aws.String("v2.0")},
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ProductCodesRejected",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
ProductCodes: []*string{aws.String("abc")},
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "UserIdsRejected",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
UserIds: []*string{aws.String("123456789012")},
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "UnsupportedAttribute_BootMode",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("bootMode"),
Value: aws.String("uefi"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "UnsupportedAttribute_LaunchPermission",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("launchPermission"),
Value: aws.String("foo"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ValidTopLevelDescription",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Description: &ec2.AttributeValue{Value: aws.String("updated")},
},
wantErr: false,
},
{
name: "ValidStructuredDescription",
input: &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("description"),
Value: aws.String("updated"),
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateModifyImageAttributeInput(tt.input)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
func TestValidateModifyImageAttributeInput_NormalizesTopLevelDescription(t *testing.T) {
input := &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Description: &ec2.AttributeValue{Value: aws.String("new-desc")},
}
require.NoError(t, ValidateModifyImageAttributeInput(input))
require.NotNil(t, input.Attribute)
assert.Equal(t, "description", *input.Attribute)
require.NotNil(t, input.Value)
assert.Equal(t, "new-desc", *input.Value)
}
func TestValidateModifyImageAttributeInput_NormalizesEmptyDescriptionToEmptyString(t *testing.T) {
// --description Value="" should clear the field, not error.
input := &ec2.ModifyImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Description: &ec2.AttributeValue{Value: aws.String("")},
}
require.NoError(t, ValidateModifyImageAttributeInput(input))
require.NotNil(t, input.Value)
assert.Equal(t, "", *input.Value)
}
func TestModifyImageAttribute_GatewayValidationFailureReturnsEarly(t *testing.T) {
_, err := ModifyImageAttribute(&ec2.ModifyImageAttributeInput{}, nil, "000000000001")
assert.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
+129
View File
@@ -0,0 +1,129 @@
package gateway_ec2_image
import (
"errors"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
"github.com/nats-io/nats.go"
)
// i386 is accepted (not scheduled) so pass-through callers don't fail validation.
var validRegisterImageArchitectures = map[string]bool{
"x86_64": true,
"arm64": true,
"i386": true,
}
// ValidateRegisterImageInput rejects inputs that ask for behaviour spinifex
// doesn't have (PV, S3 bundles, kernel/ramdisk, IMDS/TPM/ENA hints) rather
// than silently discarding them. Registration is pointer-only: caller supplies
// a snapshot and we write a config.json that references it.
func ValidateRegisterImageInput(input *ec2.RegisterImageInput) error {
if input == nil {
return errors.New(awserrors.ErrorMissingParameter)
}
if input.Name == nil || *input.Name == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if n := len(*input.Name); n < 3 || n > 128 {
return errors.New(awserrors.ErrorInvalidAMINameMalformed)
}
if input.ImageLocation != nil && *input.ImageLocation != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.Architecture != nil && *input.Architecture != "" {
if !validRegisterImageArchitectures[*input.Architecture] {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
}
if input.VirtualizationType != nil && *input.VirtualizationType != "" && *input.VirtualizationType != "hvm" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.BootMode != nil && *input.BootMode != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.KernelId != nil && *input.KernelId != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.RamdiskId != nil && *input.RamdiskId != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.TpmSupport != nil && *input.TpmSupport != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.ImdsSupport != nil && *input.ImdsSupport != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.EnaSupport != nil {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if input.SriovNetSupport != nil && *input.SriovNetSupport != "" {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
if len(input.BlockDeviceMappings) == 0 {
return errors.New(awserrors.ErrorMissingParameter)
}
root := selectRootBlockDeviceMapping(input.BlockDeviceMappings, input.RootDeviceName)
if root == nil || root.Ebs == nil || root.Ebs.SnapshotId == nil || *root.Ebs.SnapshotId == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !strings.HasPrefix(*root.Ebs.SnapshotId, "snap-") {
return errors.New(awserrors.ErrorInvalidSnapshotIDMalformed)
}
return nil
}
// selectRootBlockDeviceMapping returns the BDM matching RootDeviceName if set,
// else the first BDM carrying an EBS snapshot reference.
func selectRootBlockDeviceMapping(mappings []*ec2.BlockDeviceMapping, rootDeviceName *string) *ec2.BlockDeviceMapping {
wantName := ""
if rootDeviceName != nil {
wantName = *rootDeviceName
}
if wantName != "" {
for _, bdm := range mappings {
if bdm == nil || bdm.DeviceName == nil {
continue
}
if *bdm.DeviceName == wantName {
return bdm
}
}
return nil
}
for _, bdm := range mappings {
if bdm == nil || bdm.Ebs == nil || bdm.Ebs.SnapshotId == nil {
continue
}
return bdm
}
return nil
}
func RegisterImage(input *ec2.RegisterImageInput, natsConn *nats.Conn, accountID string) (ec2.RegisterImageOutput, error) {
var output ec2.RegisterImageOutput
if err := ValidateRegisterImageInput(input); err != nil {
return output, err
}
svc := handlers_ec2_image.NewNATSImageService(natsConn, 0)
result, err := svc.RegisterImage(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,253 @@
package gateway_ec2_image
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
)
func validRegisterImageInput() *ec2.RegisterImageInput {
return &ec2.RegisterImageInput{
Name: aws.String("my-image"),
RootDeviceName: aws.String("/dev/sda1"),
BlockDeviceMappings: []*ec2.BlockDeviceMapping{
{
DeviceName: aws.String("/dev/sda1"),
Ebs: &ec2.EbsBlockDevice{
SnapshotId: aws.String("snap-1234567890abcdef0"),
VolumeSize: aws.Int64(20),
},
},
},
}
}
func TestValidateRegisterImageInput(t *testing.T) {
tests := []struct {
name string
mutate func(*ec2.RegisterImageInput)
input *ec2.RegisterImageInput
wantErr bool
errMsg string
}{
{
name: "NilInput",
input: nil,
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MissingName",
mutate: func(i *ec2.RegisterImageInput) { i.Name = nil },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "EmptyName",
mutate: func(i *ec2.RegisterImageInput) { i.Name = aws.String("") },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "ShortName",
mutate: func(i *ec2.RegisterImageInput) { i.Name = aws.String("ab") },
wantErr: true,
errMsg: awserrors.ErrorInvalidAMINameMalformed,
},
{
name: "LongName",
mutate: func(i *ec2.RegisterImageInput) {
name := make([]byte, 129)
for j := range name {
name[j] = 'a'
}
i.Name = aws.String(string(name))
},
wantErr: true,
errMsg: awserrors.ErrorInvalidAMINameMalformed,
},
{
name: "MissingBlockDeviceMappings",
mutate: func(i *ec2.RegisterImageInput) { i.BlockDeviceMappings = nil },
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MissingSnapshotIdInRootBDM",
mutate: func(i *ec2.RegisterImageInput) {
i.BlockDeviceMappings[0].Ebs.SnapshotId = nil
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MalformedSnapshotId",
mutate: func(i *ec2.RegisterImageInput) {
i.BlockDeviceMappings[0].Ebs.SnapshotId = aws.String("not-a-snapshot")
},
wantErr: true,
errMsg: awserrors.ErrorInvalidSnapshotIDMalformed,
},
{
name: "RootDeviceNameWithNoMatchingBDM",
mutate: func(i *ec2.RegisterImageInput) {
i.RootDeviceName = aws.String("/dev/xvda")
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "InvalidArchitecture",
mutate: func(i *ec2.RegisterImageInput) {
i.Architecture = aws.String("riscv")
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ParavirtualVirtualizationRejected",
mutate: func(i *ec2.RegisterImageInput) {
i.VirtualizationType = aws.String("paravirtual")
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ImageLocationRejected",
mutate: func(i *ec2.RegisterImageInput) {
i.ImageLocation = aws.String("s3://bundles/my-ami.manifest.xml")
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "BootModeRejected",
mutate: func(i *ec2.RegisterImageInput) { i.BootMode = aws.String("uefi") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "KernelIdRejected",
mutate: func(i *ec2.RegisterImageInput) { i.KernelId = aws.String("aki-123") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "RamdiskIdRejected",
mutate: func(i *ec2.RegisterImageInput) { i.RamdiskId = aws.String("ari-123") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "TpmSupportRejected",
mutate: func(i *ec2.RegisterImageInput) { i.TpmSupport = aws.String("v2.0") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ImdsSupportRejected",
mutate: func(i *ec2.RegisterImageInput) { i.ImdsSupport = aws.String("v2.0") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "EnaSupportRejected",
mutate: func(i *ec2.RegisterImageInput) { i.EnaSupport = aws.Bool(true) },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "SriovNetSupportRejected",
mutate: func(i *ec2.RegisterImageInput) { i.SriovNetSupport = aws.String("simple") },
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ValidWithExplicitArch",
mutate: func(i *ec2.RegisterImageInput) {
i.Architecture = aws.String("arm64")
i.VirtualizationType = aws.String("hvm")
},
wantErr: false,
},
{
name: "ValidNoRootDeviceName",
mutate: func(i *ec2.RegisterImageInput) {
i.RootDeviceName = nil
},
wantErr: false,
},
{
name: "ValidMinimal",
wantErr: false,
},
{
name: "ValidWithTagSpecifications",
mutate: func(i *ec2.RegisterImageInput) {
i.TagSpecifications = []*ec2.TagSpecification{
{
ResourceType: aws.String("image"),
Tags: []*ec2.Tag{
{Key: aws.String("Env"), Value: aws.String("test")},
},
},
}
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var input *ec2.RegisterImageInput
if tt.input == nil && tt.name != "NilInput" {
input = validRegisterImageInput()
} else {
input = tt.input
}
if tt.mutate != nil {
tt.mutate(input)
}
err := ValidateRegisterImageInput(input)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
func TestRegisterImage_GatewayValidationFailureReturnsEarly(t *testing.T) {
// nil natsConn is fine here: validation rejects the input before any
// NATS round-trip is attempted, so we exercise the early-exit path.
_, err := RegisterImage(&ec2.RegisterImageInput{}, nil, "000000000001")
assert.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
func TestSelectRootBlockDeviceMapping_NoRootDeviceName(t *testing.T) {
mappings := []*ec2.BlockDeviceMapping{
nil,
{Ebs: nil},
{Ebs: &ec2.EbsBlockDevice{SnapshotId: aws.String("snap-first")}},
{Ebs: &ec2.EbsBlockDevice{SnapshotId: aws.String("snap-second")}},
}
bdm := selectRootBlockDeviceMapping(mappings, nil)
assert.NotNil(t, bdm)
assert.Equal(t, "snap-first", *bdm.Ebs.SnapshotId)
// Empty (non-nil) RootDeviceName behaves the same as nil.
bdm = selectRootBlockDeviceMapping(mappings, aws.String(""))
assert.NotNil(t, bdm)
assert.Equal(t, "snap-first", *bdm.Ebs.SnapshotId)
// Nothing usable.
bdm = selectRootBlockDeviceMapping([]*ec2.BlockDeviceMapping{nil, {}}, nil)
assert.Nil(t, bdm)
}
@@ -0,0 +1,47 @@
package gateway_ec2_image
import (
"errors"
"strings"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
"github.com/nats-io/nats.go"
)
// ValidateResetImageAttributeInput accepts only description; launchPermission
// is out of scope.
func ValidateResetImageAttributeInput(input *ec2.ResetImageAttributeInput) error {
if input == nil {
return errors.New(awserrors.ErrorMissingParameter)
}
if input.ImageId == nil || *input.ImageId == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if !strings.HasPrefix(*input.ImageId, "ami-") {
return errors.New(awserrors.ErrorInvalidAMIIDMalformed)
}
if input.Attribute == nil || *input.Attribute == "" {
return errors.New(awserrors.ErrorMissingParameter)
}
if *input.Attribute != ec2.ImageAttributeNameDescription {
return errors.New(awserrors.ErrorInvalidParameterValue)
}
return nil
}
func ResetImageAttribute(input *ec2.ResetImageAttributeInput, natsConn *nats.Conn, accountID string) (ec2.ResetImageAttributeOutput, error) {
var output ec2.ResetImageAttributeOutput
if err := ValidateResetImageAttributeInput(input); err != nil {
return output, err
}
svc := handlers_ec2_image.NewNATSImageService(natsConn, 0)
result, err := svc.ResetImageAttribute(input, accountID)
if err != nil {
return output, err
}
return *result, nil
}
@@ -0,0 +1,93 @@
package gateway_ec2_image
import (
"testing"
"github.com/aws/aws-sdk-go/aws"
"github.com/aws/aws-sdk-go/service/ec2"
"github.com/mulgadc/spinifex/spinifex/awserrors"
"github.com/stretchr/testify/assert"
)
func TestValidateResetImageAttributeInput(t *testing.T) {
tests := []struct {
name string
input *ec2.ResetImageAttributeInput
wantErr bool
errMsg string
}{
{
name: "NilInput",
input: nil,
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "EmptyInput",
input: &ec2.ResetImageAttributeInput{},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MissingAttribute",
input: &ec2.ResetImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
},
wantErr: true,
errMsg: awserrors.ErrorMissingParameter,
},
{
name: "MalformedImageId",
input: &ec2.ResetImageAttributeInput{
ImageId: aws.String("not-an-ami"),
Attribute: aws.String("description"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidAMIIDMalformed,
},
{
name: "UnsupportedAttribute_LaunchPermission",
input: &ec2.ResetImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("launchPermission"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "UnsupportedAttribute_BlockDeviceMapping",
input: &ec2.ResetImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("blockDeviceMapping"),
},
wantErr: true,
errMsg: awserrors.ErrorInvalidParameterValue,
},
{
name: "ValidDescription",
input: &ec2.ResetImageAttributeInput{
ImageId: aws.String("ami-1234567890abcdef0"),
Attribute: aws.String("description"),
},
wantErr: false,
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := ValidateResetImageAttributeInput(tt.input)
if tt.wantErr {
assert.Error(t, err)
assert.Equal(t, tt.errMsg, err.Error())
} else {
assert.NoError(t, err)
}
})
}
}
func TestResetImageAttribute_GatewayValidationFailureReturnsEarly(t *testing.T) {
_, err := ResetImageAttribute(&ec2.ResetImageAttributeInput{}, nil, "000000000001")
assert.Error(t, err)
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
}
+2 -1
View File
@@ -918,7 +918,8 @@ func TestEC2ActionMapCompleteness(t *testing.T) {
"TerminateInstances", "RebootInstances", "DescribeInstanceTypes", "GetConsoleOutput",
"ModifyInstanceAttribute", "DescribeInstanceAttribute",
"CreateKeyPair", "DeleteKeyPair", "DescribeKeyPairs", "ImportKeyPair",
"DescribeImages", "CreateImage",
"DescribeImages", "CreateImage", "DeregisterImage", "RegisterImage", "CopyImage",
"DescribeImageAttribute", "ModifyImageAttribute", "ResetImageAttribute",
"DescribeRegions", "DescribeAvailabilityZones",
"DescribeVolumes", "ModifyVolume", "CreateVolume", "DeleteVolume",
"AttachVolume", "DescribeVolumeStatus", "DetachVolume",
+514 -86
View File
@@ -7,6 +7,7 @@ import (
"fmt"
"io"
"log/slog"
"maps"
"strconv"
"strings"
"time"
@@ -196,15 +197,12 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun
}
}
// Determine AMI ownership. Phase4+ AMIs store the creator's account ID
// in ImageOwnerAlias. Pre-phase4 AMIs have non-account values like "self"
// or "spinifex" and are treated as system/public images visible to all.
// AMIs with a non-account-ID owner (e.g. "spinifex") are system AMIs
// visible to all; account-ID owners are private.
amiOwner := amiMeta.ImageOwnerAlias
isSystemAMI := !utils.IsAccountID(amiOwner)
isSystemAMI := amiOwner != "" && !utils.IsAccountID(amiOwner)
// Visibility check: callers can only see their own AMIs and system AMIs.
// This runs regardless of whether an owner filter is specified.
if !isSystemAMI && amiOwner != accountID {
if !callerCanReadAMI(amiMeta, accountID) {
continue
}
@@ -264,33 +262,12 @@ func (s *ImageServiceImpl) DescribeImages(input *ec2.DescribeImagesInput, accoun
Hypervisor: aws.String("xen"), // Default hypervisor
}
// Add root device mapping
if amiMeta.RootDeviceType == "ebs" {
if bdms := synthesizeRootBlockDeviceMapping(amiMeta); bdms != nil {
image.RootDeviceName = aws.String("/dev/sda1")
image.BlockDeviceMappings = []*ec2.BlockDeviceMapping{
{
DeviceName: aws.String("/dev/sda1"),
Ebs: &ec2.EbsBlockDevice{
VolumeSize: aws.Int64(utils.SafeUint64ToInt64(amiMeta.VolumeSizeGiB)),
VolumeType: aws.String("gp3"),
DeleteOnTermination: aws.Bool(true),
Encrypted: aws.Bool(false),
},
},
}
image.BlockDeviceMappings = bdms
}
// Add tags if available
if len(amiMeta.Tags) > 0 {
tags := make([]*ec2.Tag, 0, len(amiMeta.Tags))
for key, value := range amiMeta.Tags {
tags = append(tags, &ec2.Tag{
Key: aws.String(key),
Value: aws.String(value),
})
}
image.Tags = tags
}
image.Tags = utils.MapToEC2Tags(amiMeta.Tags)
// Apply filters against the fully-built image
if len(parsedFilters) > 0 && !imageMatchesFilters(image, parsedFilters, amiMeta.Tags) {
@@ -446,40 +423,21 @@ func (s *ImageServiceImpl) CreateImageFromInstance(params CreateImageParams, acc
}
// Step 5: Build and store AMI config
description := aws.StringValue(input.Description)
amiConfig := viperblock.VBState{
VolumeConfig: viperblock.VolumeConfig{
AMIMetadata: viperblock.AMIMetadata{
ImageID: amiID,
Name: name,
Description: description,
SnapshotID: snapshotID,
Architecture: sourceAMI.Architecture,
PlatformDetails: sourceAMI.PlatformDetails,
Virtualization: sourceAMI.Virtualization,
VolumeSizeGiB: volumeSizeGiB,
CreationDate: time.Now(),
RootDeviceType: "ebs",
ImageOwnerAlias: accountID,
},
},
meta := viperblock.AMIMetadata{
ImageID: amiID,
Name: name,
Description: aws.StringValue(input.Description),
SnapshotID: snapshotID,
Architecture: sourceAMI.Architecture,
PlatformDetails: sourceAMI.PlatformDetails,
Virtualization: sourceAMI.Virtualization,
VolumeSizeGiB: volumeSizeGiB,
CreationDate: time.Now(),
RootDeviceType: ec2.DeviceTypeEbs,
ImageOwnerAlias: accountID,
}
configData, err := json.Marshal(amiConfig)
if err != nil {
slog.Error("CreateImageFromInstance: failed to marshal AMI config", "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
configKey := fmt.Sprintf("%s/config.json", amiID)
_, err = s.store.PutObject(&s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(configKey),
Body: bytes.NewReader(configData),
ContentType: aws.String("application/json"),
})
if err != nil {
if err := s.putAMIConfig(amiID, meta); err != nil {
slog.Error("CreateImageFromInstance: failed to store AMI config", "amiId", amiID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
@@ -607,6 +565,8 @@ func (s *ImageServiceImpl) getVolumeConfig(volumeID string) (*viperblock.VolumeC
}
// amiNameExists checks if any existing AMI already uses the given name.
// NoSuchKey is skipped (concurrent deregister race); any other read/decode
// error is surfaced so we don't silently allow a duplicate.
func (s *ImageServiceImpl) amiNameExists(name string) (bool, error) {
listResult, err := s.store.ListObjectsV2(&s3.ListObjectsV2Input{
Bucket: aws.String(s.bucketName),
@@ -627,14 +587,17 @@ func (s *ImageServiceImpl) amiNameExists(name string) (bool, error) {
Key: aws.String(configKey),
})
if err != nil {
continue
if objectstore.IsNoSuchKeyError(err) {
continue
}
return false, fmt.Errorf("amiNameExists: failed to read %s: %w", configKey, err)
}
var vbState viperblock.VBState
decodeErr := json.NewDecoder(result.Body).Decode(&vbState)
_ = result.Body.Close()
if decodeErr != nil {
continue
return false, fmt.Errorf("amiNameExists: failed to decode %s: %w", configKey, decodeErr)
}
if vbState.VolumeConfig.AMIMetadata.Name == name {
@@ -645,8 +608,13 @@ func (s *ImageServiceImpl) amiNameExists(name string) (bool, error) {
return false, nil
}
// GetAMIConfig retrieves the AMI metadata for a given image ID from S3.
// Returns NoSuchKeyError if the AMI does not exist.
// ErrCorruptAMIConfig wraps JSON decode failures on AMI config.json so callers
// can distinguish a truly-missing AMI from one whose config exists but can't
// be parsed.
var ErrCorruptAMIConfig = errors.New("corrupt AMI config")
// GetAMIConfig returns NoSuchKeyError if the AMI is missing, or an error
// wrapping ErrCorruptAMIConfig on decode failure.
func (s *ImageServiceImpl) GetAMIConfig(imageID string) (viperblock.AMIMetadata, error) {
configKey := fmt.Sprintf("%s/config.json", imageID)
result, err := s.store.GetObject(&s3.GetObjectInput{
@@ -660,11 +628,85 @@ func (s *ImageServiceImpl) GetAMIConfig(imageID string) (viperblock.AMIMetadata,
var vbState viperblock.VBState
if err := json.NewDecoder(result.Body).Decode(&vbState); err != nil {
return viperblock.AMIMetadata{}, err
return viperblock.AMIMetadata{}, fmt.Errorf("%w: %s: %v", ErrCorruptAMIConfig, configKey, err)
}
return vbState.VolumeConfig.AMIMetadata, nil
}
// putAMIConfig writes AMI metadata to {imageID}/config.json, preserving the
// VBState wrapper used by GetAMIConfig.
func (s *ImageServiceImpl) putAMIConfig(imageID string, meta viperblock.AMIMetadata) error {
state := viperblock.VBState{
VolumeConfig: viperblock.VolumeConfig{
AMIMetadata: meta,
},
}
data, err := json.Marshal(state)
if err != nil {
return err
}
configKey := fmt.Sprintf("%s/config.json", imageID)
_, err = s.store.PutObject(&s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(configKey),
Body: bytes.NewReader(data),
ContentType: aws.String("application/json"),
})
return err
}
// checkAMIOwnership rejects cross-account and system-AMI mutations. Empty
// owner is ServerInternal (corrupt config) rather than a misleading 403.
func (s *ImageServiceImpl) checkAMIOwnership(meta viperblock.AMIMetadata, accountID string) error {
owner := meta.ImageOwnerAlias
if owner == "" {
slog.Error("checkAMIOwnership: AMI config has empty ImageOwnerAlias", "imageId", meta.ImageID)
return errors.New(awserrors.ErrorServerInternal)
}
if !utils.IsAccountID(owner) || owner != accountID {
return errors.New(awserrors.ErrorUnauthorizedOperation)
}
return nil
}
// callerCanReadAMI: empty owner is invisible (corrupt); non-account owner is
// a system AMI visible to all; account owner is private to that account.
func callerCanReadAMI(meta viperblock.AMIMetadata, accountID string) bool {
owner := meta.ImageOwnerAlias
if owner == "" {
return false
}
if !utils.IsAccountID(owner) {
return true
}
return owner == accountID
}
// loadAMIForMutation fetches the AMI and enforces ownership. Cross-account
// callers see UnauthorizedOperation, not NotFound (they already know the ID).
// No CAS: concurrent writers last-write-wins on the full struct.
func (s *ImageServiceImpl) loadAMIForMutation(imageID, accountID string) (viperblock.AMIMetadata, error) {
if !strings.HasPrefix(imageID, "ami-") {
return viperblock.AMIMetadata{}, errors.New(awserrors.ErrorInvalidAMIIDMalformed)
}
meta, err := s.GetAMIConfig(imageID)
if err != nil {
if objectstore.IsNoSuchKeyError(err) {
return viperblock.AMIMetadata{}, errors.New(awserrors.ErrorInvalidAMIIDNotFound)
}
slog.Error("loadAMIForMutation: failed to read AMI config", "imageId", imageID, "err", err)
return viperblock.AMIMetadata{}, errors.New(awserrors.ErrorServerInternal)
}
if err := s.checkAMIOwnership(meta, accountID); err != nil {
return viperblock.AMIMetadata{}, err
}
return meta, nil
}
// putSnapshotMetadata stores snapshot metadata in S3 using the canonical SnapshotConfig type
func (s *ImageServiceImpl) putSnapshotMetadata(snapshotID, volumeID string, volumeSizeGiB uint64, accountID string) error {
cfg := handlers_ec2_snapshot.SnapshotConfig{
@@ -676,42 +718,428 @@ func (s *ImageServiceImpl) putSnapshotMetadata(snapshotID, volumeID string, volu
StartTime: time.Now(),
OwnerID: accountID,
}
return handlers_ec2_snapshot.WriteSnapshotConfig(s.store, s.bucketName, snapshotID, &cfg)
}
data, err := json.Marshal(cfg)
if err != nil {
return err
// CopyImage clones an AMI same-region, metadata-only: the new snapshot shares
// the source's VolumeID and a fresh config.json points at it.
// Source visibility is checked before the O(n) name-uniqueness scan so typos
// and cross-account sources fast-fail without a full AMI listing.
func (s *ImageServiceImpl) CopyImage(input *ec2.CopyImageInput, accountID string) (*ec2.CopyImageOutput, error) {
if input == nil || input.Name == nil || *input.Name == "" ||
input.SourceImageId == nil || *input.SourceImageId == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
key := fmt.Sprintf("%s/metadata.json", snapshotID)
_, err = s.store.PutObject(&s3.PutObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(key),
Body: bytes.NewReader(data),
ContentType: aws.String("application/json"),
})
return err
name := *input.Name
sourceImageID := *input.SourceImageId
srcMeta, err := s.GetAMIConfig(sourceImageID)
if err != nil {
// Corrupt source is treated as NotFound so callers can't tell which
// half of the AMI/snapshot pair is broken.
if objectstore.IsNoSuchKeyError(err) || errors.Is(err, ErrCorruptAMIConfig) {
return nil, errors.New(awserrors.ErrorInvalidAMIIDNotFound)
}
slog.Error("CopyImage: failed to read source AMI config", "sourceImageId", sourceImageID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if !callerCanReadAMI(srcMeta, accountID) {
return nil, errors.New(awserrors.ErrorInvalidAMIIDNotFound)
}
// Orphaned source (missing SnapshotID, or snapshot gone/corrupt) is
// reported as NotFound — don't leak the half-broken state.
if srcMeta.SnapshotID == "" {
return nil, errors.New(awserrors.ErrorInvalidAMIIDNotFound)
}
srcSnap, err := handlers_ec2_snapshot.ReadSnapshotConfig(s.store, s.bucketName, srcMeta.SnapshotID)
if err != nil {
// Bundled system AMIs (admin-imported via `spx admin images import`)
// store blocks under ami-xxx/ with no standalone snap-xxx/metadata.json
// — the SnapshotID field is a viperblock-internal snap reference. To
// keep copy-image AWS-compatible (copy of a public/shared AMI must
// succeed), synthesize a minimal snap view from the AMI itself: the
// bundled prefix IS the volume, so VolumeID = sourceImageID.
if objectstore.IsNoSuchKeyError(err) && srcMeta.ImageOwnerAlias != "" && !utils.IsAccountID(srcMeta.ImageOwnerAlias) {
srcSnap = &handlers_ec2_snapshot.SnapshotConfig{
SnapshotID: srcMeta.SnapshotID,
VolumeID: sourceImageID,
VolumeSize: utils.SafeUint64ToInt64(srcMeta.VolumeSizeGiB),
}
} else if objectstore.IsNoSuchKeyError(err) || errors.Is(err, handlers_ec2_snapshot.ErrCorruptSnapshotMetadata) {
return nil, errors.New(awserrors.ErrorInvalidAMIIDNotFound)
} else {
slog.Error("CopyImage: failed to read source snapshot metadata",
"sourceImageId", sourceImageID, "snapshotId", srcMeta.SnapshotID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
}
if exists, err := s.amiNameExists(name); err != nil {
slog.Error("CopyImage: failed to check AMI name uniqueness", "name", name, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
} else if exists {
return nil, errors.New(awserrors.ErrorInvalidAMINameDuplicate)
}
newSnapshotID := utils.GenerateResourceID("snap")
newImageID := utils.GenerateResourceID("ami")
// New snap shares source VolumeID — no block copy.
snapSizeGiB := uint64(0)
if srcSnap.VolumeSize > 0 {
snapSizeGiB = uint64(srcSnap.VolumeSize)
}
if err := s.putSnapshotMetadata(newSnapshotID, srcSnap.VolumeID, snapSizeGiB, accountID); err != nil {
slog.Error("CopyImage: failed to write snapshot metadata", "snapshotId", newSnapshotID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
description := srcMeta.Description
if input.Description != nil {
description = *input.Description
}
rootDeviceType := srcMeta.RootDeviceType
if rootDeviceType == "" {
rootDeviceType = "ebs"
}
tags := mergeCopyImageTags(srcMeta.Tags, input.TagSpecifications, aws.BoolValue(input.CopyImageTags))
meta := viperblock.AMIMetadata{
ImageID: newImageID,
Name: name,
Description: description,
SnapshotID: newSnapshotID,
Architecture: srcMeta.Architecture,
PlatformDetails: srcMeta.PlatformDetails,
Virtualization: srcMeta.Virtualization,
VolumeSizeGiB: srcMeta.VolumeSizeGiB,
RootDeviceType: rootDeviceType,
ImageOwnerAlias: accountID,
CreationDate: time.Now(),
Tags: tags,
}
if err := s.putAMIConfig(newImageID, meta); err != nil {
slog.Error("CopyImage: failed to write AMI config",
"amiId", newImageID, "orphanSnapshotId", newSnapshotID, "err", err)
// Best-effort rollback of the orphaned snapshot metadata.
snapKey := handlers_ec2_snapshot.GetSnapshotKey(newSnapshotID)
if _, delErr := s.store.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(snapKey),
}); delErr != nil {
slog.Error("CopyImage: failed to roll back orphaned snapshot metadata",
"snapshotId", newSnapshotID, "err", delErr)
}
return nil, errors.New(awserrors.ErrorServerInternal)
}
slog.Info("CopyImage completed",
"sourceImageId", sourceImageID, "newImageId", newImageID,
"sourceSnapshotId", srcMeta.SnapshotID, "newSnapshotId", newSnapshotID,
"accountId", accountID)
return &ec2.CopyImageOutput{ImageId: aws.String(newImageID)}, nil
}
func (s *ImageServiceImpl) CopyImage(input *ec2.CopyImageInput, accountID string) (*ec2.CopyImageOutput, error) {
return nil, errors.New("CopyImage not yet implemented")
// mergeCopyImageTags seeds with source tags when copyImageTags is true, then
// lets image-resource TagSpecifications override colliding keys. Non-image tag
// specs are ignored.
func mergeCopyImageTags(srcTags map[string]string, specs []*ec2.TagSpecification, copyImageTags bool) map[string]string {
merged := make(map[string]string)
if copyImageTags {
maps.Copy(merged, srcTags)
}
maps.Copy(merged, utils.ExtractTags(specs, "image"))
if len(merged) == 0 {
return nil
}
return merged
}
// DescribeImageAttribute supports description and blockDeviceMapping only.
// Cross-account reads return NotFound so the caller can't learn the ID exists
// in another account.
func (s *ImageServiceImpl) DescribeImageAttribute(input *ec2.DescribeImageAttributeInput, accountID string) (*ec2.DescribeImageAttributeOutput, error) {
return nil, errors.New("DescribeImageAttribute not yet implemented")
if input == nil || input.ImageId == nil || *input.ImageId == "" ||
input.Attribute == nil || *input.Attribute == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
imageID := *input.ImageId
attribute := *input.Attribute
meta, err := s.GetAMIConfig(imageID)
if err != nil {
if objectstore.IsNoSuchKeyError(err) {
return nil, errors.New(awserrors.ErrorInvalidAMIIDNotFound)
}
slog.Error("DescribeImageAttribute: failed to read AMI config", "imageId", imageID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
if !callerCanReadAMI(meta, accountID) {
return nil, errors.New(awserrors.ErrorInvalidAMIIDNotFound)
}
output := &ec2.DescribeImageAttributeOutput{
ImageId: aws.String(imageID),
}
switch attribute {
case ec2.ImageAttributeNameDescription:
output.Description = &ec2.AttributeValue{Value: aws.String(meta.Description)}
case ec2.ImageAttributeNameBlockDeviceMapping:
output.BlockDeviceMappings = synthesizeRootBlockDeviceMapping(meta)
default:
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
slog.Info("DescribeImageAttribute completed", "imageId", imageID, "attribute", attribute, "accountId", accountID)
return output, nil
}
// synthesizeRootBlockDeviceMapping returns /dev/sda1 with size+snapshot from
// AMIMetadata, or nil for non-EBS AMIs.
func synthesizeRootBlockDeviceMapping(meta viperblock.AMIMetadata) []*ec2.BlockDeviceMapping {
if meta.RootDeviceType != "ebs" {
return nil
}
ebs := &ec2.EbsBlockDevice{
VolumeSize: aws.Int64(utils.SafeUint64ToInt64(meta.VolumeSizeGiB)),
VolumeType: aws.String("gp3"),
DeleteOnTermination: aws.Bool(true),
Encrypted: aws.Bool(false),
}
if meta.SnapshotID != "" {
ebs.SnapshotId = aws.String(meta.SnapshotID)
}
return []*ec2.BlockDeviceMapping{{
DeviceName: aws.String("/dev/sda1"),
Ebs: ebs,
}}
}
// RegisterImage writes AMI metadata pointing at an existing snapshot. Never
// touches block data. The snapshot read runs before the O(n) name-uniqueness
// scan so a missing snapshot fast-fails.
func (s *ImageServiceImpl) RegisterImage(input *ec2.RegisterImageInput, accountID string) (*ec2.RegisterImageOutput, error) {
return nil, errors.New("RegisterImage not yet implemented")
if input == nil || input.Name == nil || *input.Name == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
name := *input.Name
rootBDM := pickRootSnapshotBDM(input.BlockDeviceMappings, input.RootDeviceName)
if rootBDM == nil {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
snapshotID := *rootBDM.Ebs.SnapshotId
snapCfg, err := handlers_ec2_snapshot.ReadSnapshotConfig(s.store, s.bucketName, snapshotID)
if err != nil {
// Corrupt snapshot is surfaced as NotFound, same as CopyImage.
if objectstore.IsNoSuchKeyError(err) || errors.Is(err, handlers_ec2_snapshot.ErrCorruptSnapshotMetadata) {
return nil, errors.New(awserrors.ErrorInvalidSnapshotNotFound)
}
slog.Error("RegisterImage: failed to read snapshot metadata", "snapshotId", snapshotID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
// Only the snapshot owner (or any caller for system snapshots) can register.
if utils.IsAccountID(snapCfg.OwnerID) && snapCfg.OwnerID != accountID {
slog.Warn("RegisterImage: rejected cross-account snapshot",
"snapshotId", snapshotID, "snapshotOwner", snapCfg.OwnerID, "accountId", accountID)
return nil, errors.New(awserrors.ErrorUnauthorizedOperation)
}
snapSizeGiB := uint64(0)
if snapCfg.VolumeSize > 0 {
snapSizeGiB = uint64(snapCfg.VolumeSize)
}
volumeSizeGiB := snapSizeGiB
if rootBDM.Ebs.VolumeSize != nil && *rootBDM.Ebs.VolumeSize > 0 {
requested := uint64(*rootBDM.Ebs.VolumeSize)
if requested < snapSizeGiB {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
volumeSizeGiB = requested
}
if exists, err := s.amiNameExists(name); err != nil {
slog.Error("RegisterImage: failed to check AMI name uniqueness", "name", name, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
} else if exists {
return nil, errors.New(awserrors.ErrorInvalidAMINameDuplicate)
}
architecture := "x86_64"
if input.Architecture != nil && *input.Architecture != "" {
architecture = *input.Architecture
}
virtualization := "hvm"
if input.VirtualizationType != nil && *input.VirtualizationType != "" {
virtualization = *input.VirtualizationType
}
description := ""
if input.Description != nil {
description = *input.Description
}
tags := utils.ExtractTags(input.TagSpecifications, "image")
amiID := utils.GenerateResourceID("ami")
meta := viperblock.AMIMetadata{
ImageID: amiID,
Name: name,
Description: description,
SnapshotID: snapshotID,
Architecture: architecture,
PlatformDetails: "Linux/UNIX",
Virtualization: virtualization,
VolumeSizeGiB: volumeSizeGiB,
RootDeviceType: "ebs",
ImageOwnerAlias: accountID,
CreationDate: time.Now(),
Tags: tags,
}
if err := s.putAMIConfig(amiID, meta); err != nil {
slog.Error("RegisterImage: failed to write AMI config", "amiId", amiID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
slog.Info("RegisterImage completed", "amiId", amiID, "snapshotId", snapshotID, "accountId", accountID)
return &ec2.RegisterImageOutput{ImageId: aws.String(amiID)}, nil
}
// pickRootSnapshotBDM returns the BDM matching RootDeviceName (if set) that
// carries a non-empty EBS snapshot reference, else the first such BDM.
func pickRootSnapshotBDM(mappings []*ec2.BlockDeviceMapping, rootDeviceName *string) *ec2.BlockDeviceMapping {
wantName := ""
if rootDeviceName != nil {
wantName = *rootDeviceName
}
for _, bdm := range mappings {
if bdm == nil || bdm.Ebs == nil || bdm.Ebs.SnapshotId == nil || *bdm.Ebs.SnapshotId == "" {
continue
}
if wantName != "" {
if bdm.DeviceName == nil || *bdm.DeviceName != wantName {
continue
}
}
return bdm
}
return nil
}
// DeregisterImage hard-deletes config.json. Backing snapshot is untouched, so
// operators run delete-snapshot separately to reclaim block storage.
func (s *ImageServiceImpl) DeregisterImage(input *ec2.DeregisterImageInput, accountID string) (*ec2.DeregisterImageOutput, error) {
return nil, errors.New("DeregisterImage not yet implemented")
if input == nil || input.ImageId == nil || *input.ImageId == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
imageID := *input.ImageId
if _, err := s.loadAMIForMutation(imageID, accountID); err != nil {
return nil, err
}
configKey := fmt.Sprintf("%s/config.json", imageID)
if _, err := s.store.DeleteObject(&s3.DeleteObjectInput{
Bucket: aws.String(s.bucketName),
Key: aws.String(configKey),
}); err != nil {
slog.Error("DeregisterImage: failed to delete AMI config", "imageId", imageID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
slog.Info("DeregisterImage completed", "imageId", imageID, "accountId", accountID)
return &ec2.DeregisterImageOutput{}, nil
}
// ModifyImageAttribute writes a modifiable AMI attribute. Gateway normalises
// into Attribute+Value; only description is writable. Ownership is checked
// before the attribute switch so cross-account callers always see
// UnauthorizedOperation.
func (s *ImageServiceImpl) ModifyImageAttribute(input *ec2.ModifyImageAttributeInput, accountID string) (*ec2.ModifyImageAttributeOutput, error) {
return nil, errors.New("ModifyImageAttribute not yet implemented")
if input == nil || input.ImageId == nil || *input.ImageId == "" ||
input.Attribute == nil || *input.Attribute == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
imageID := *input.ImageId
attribute := *input.Attribute
meta, err := s.loadAMIForMutation(imageID, accountID)
if err != nil {
return nil, err
}
if attribute != ec2.ImageAttributeNameDescription {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
newValue := ""
if input.Value != nil {
newValue = *input.Value
}
// No-op guard: Terraform aws_ami refresh otherwise churns out no-op writes.
if meta.Description == newValue {
slog.Info("ModifyImageAttribute no-op", "imageId", imageID, "attribute", attribute, "accountId", accountID)
return &ec2.ModifyImageAttributeOutput{}, nil
}
meta.Description = newValue
if err := s.putAMIConfig(imageID, meta); err != nil {
slog.Error("ModifyImageAttribute: failed to write AMI config", "imageId", imageID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
slog.Info("ModifyImageAttribute completed", "imageId", imageID, "attribute", attribute, "accountId", accountID)
return &ec2.ModifyImageAttributeOutput{}, nil
}
// ResetImageAttribute clears the description (the only supported attribute).
// launchPermission — AWS's default reset target — is out of scope.
func (s *ImageServiceImpl) ResetImageAttribute(input *ec2.ResetImageAttributeInput, accountID string) (*ec2.ResetImageAttributeOutput, error) {
return nil, errors.New("ResetImageAttribute not yet implemented")
if input == nil || input.ImageId == nil || *input.ImageId == "" ||
input.Attribute == nil || *input.Attribute == "" {
return nil, errors.New(awserrors.ErrorMissingParameter)
}
imageID := *input.ImageId
attribute := *input.Attribute
meta, err := s.loadAMIForMutation(imageID, accountID)
if err != nil {
return nil, err
}
if attribute != ec2.ImageAttributeNameDescription {
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
}
if meta.Description == "" {
slog.Info("ResetImageAttribute no-op", "imageId", imageID, "attribute", attribute, "accountId", accountID)
return &ec2.ResetImageAttributeOutput{}, nil
}
meta.Description = ""
if err := s.putAMIConfig(imageID, meta); err != nil {
slog.Error("ResetImageAttribute: failed to write AMI config", "imageId", imageID, "err", err)
return nil, errors.New(awserrors.ErrorServerInternal)
}
slog.Info("ResetImageAttribute completed", "imageId", imageID, "attribute", attribute, "accountId", accountID)
return &ec2.ResetImageAttributeOutput{}, nil
}
File diff suppressed because it is too large Load Diff
+6 -7
View File
@@ -1,7 +1,6 @@
package handlers_ec2_image
import (
"errors"
"time"
"github.com/aws/aws-sdk-go/service/ec2"
@@ -31,25 +30,25 @@ func (s *NATSImageService) CreateImage(input *ec2.CreateImageInput, accountID st
}
func (s *NATSImageService) CopyImage(input *ec2.CopyImageInput, accountID string) (*ec2.CopyImageOutput, error) {
return nil, errors.New("CopyImage not yet implemented")
return utils.NATSRequest[ec2.CopyImageOutput](s.natsConn, "ec2.CopyImage", input, 30*time.Second, accountID)
}
func (s *NATSImageService) DescribeImageAttribute(input *ec2.DescribeImageAttributeInput, accountID string) (*ec2.DescribeImageAttributeOutput, error) {
return nil, errors.New("DescribeImageAttribute not yet implemented")
return utils.NATSRequest[ec2.DescribeImageAttributeOutput](s.natsConn, "ec2.DescribeImageAttribute", input, 30*time.Second, accountID)
}
func (s *NATSImageService) RegisterImage(input *ec2.RegisterImageInput, accountID string) (*ec2.RegisterImageOutput, error) {
return nil, errors.New("RegisterImage not yet implemented")
return utils.NATSRequest[ec2.RegisterImageOutput](s.natsConn, "ec2.RegisterImage", input, 30*time.Second, accountID)
}
func (s *NATSImageService) DeregisterImage(input *ec2.DeregisterImageInput, accountID string) (*ec2.DeregisterImageOutput, error) {
return nil, errors.New("DeregisterImage not yet implemented")
return utils.NATSRequest[ec2.DeregisterImageOutput](s.natsConn, "ec2.DeregisterImage", input, 30*time.Second, accountID)
}
func (s *NATSImageService) ModifyImageAttribute(input *ec2.ModifyImageAttributeInput, accountID string) (*ec2.ModifyImageAttributeOutput, error) {
return nil, errors.New("ModifyImageAttribute not yet implemented")
return utils.NATSRequest[ec2.ModifyImageAttributeOutput](s.natsConn, "ec2.ModifyImageAttribute", input, 30*time.Second, accountID)
}
func (s *NATSImageService) ResetImageAttribute(input *ec2.ResetImageAttributeInput, accountID string) (*ec2.ResetImageAttributeOutput, error) {
return nil, errors.New("ResetImageAttribute not yet implemented")
return utils.NATSRequest[ec2.ResetImageAttributeOutput](s.natsConn, "ec2.ResetImageAttribute", input, 30*time.Second, accountID)
}
+36 -23
View File
@@ -104,56 +104,69 @@ func NewSnapshotServiceImplWithStore(cfg *config.Config, store objectstore.Objec
return svc
}
// getSnapshotKey returns the S3 key for storing snapshot metadata.
// Uses metadata.json to avoid conflicting with viperblock's config.json
// which stores the SnapshotState (block map references, source volume, etc).
func getSnapshotKey(snapshotID string) string {
// GetSnapshotKey uses metadata.json to avoid colliding with viperblock's
// config.json (which stores SnapshotState: block map, source volume, etc).
func GetSnapshotKey(snapshotID string) string {
return fmt.Sprintf("%s/metadata.json", snapshotID)
}
// getSnapshotConfig retrieves snapshot config from S3
func (s *SnapshotServiceImpl) getSnapshotConfig(snapshotID string) (*SnapshotConfig, error) {
key := getSnapshotKey(snapshotID)
// ErrCorruptSnapshotMetadata lets callers distinguish a missing snapshot from
// one whose metadata.json can't be parsed.
var ErrCorruptSnapshotMetadata = errors.New("corrupt snapshot metadata")
result, err := s.store.GetObject(&s3.GetObjectInput{
Bucket: aws.String(s.config.Predastore.Bucket),
// ReadSnapshotConfig reads {snapshotID}/metadata.json. Object-store errors are
// returned unchanged; callers map NoSuchKey to their preferred AWS error.
// Decode failures wrap ErrCorruptSnapshotMetadata.
func ReadSnapshotConfig(store objectstore.ObjectStore, bucket, snapshotID string) (*SnapshotConfig, error) {
key := GetSnapshotKey(snapshotID)
result, err := store.GetObject(&s3.GetObjectInput{
Bucket: aws.String(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 nil, fmt.Errorf("%w: %v", ErrCorruptSnapshotMetadata, err)
}
return &cfg, nil
}
// putSnapshotConfig stores snapshot config to S3
func (s *SnapshotServiceImpl) putSnapshotConfig(snapshotID string, cfg *SnapshotConfig) error {
key := getSnapshotKey(snapshotID)
// WriteSnapshotConfig writes the SnapshotConfig to {snapshotID}/metadata.json.
func WriteSnapshotConfig(store objectstore.ObjectStore, bucket, snapshotID string, cfg *SnapshotConfig) error {
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),
_, err = store.PutObject(&s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(GetSnapshotKey(snapshotID)),
Body: bytes.NewReader(data),
ContentType: aws.String("application/json"),
})
return err
}
// getSnapshotConfig translates NoSuchKey to InvalidSnapshot.NotFound.
func (s *SnapshotServiceImpl) getSnapshotConfig(snapshotID string) (*SnapshotConfig, error) {
cfg, err := ReadSnapshotConfig(s.store, s.config.Predastore.Bucket, snapshotID)
if err != nil {
if objectstore.IsNoSuchKeyError(err) {
return nil, errors.New(awserrors.ErrorInvalidSnapshotNotFound)
}
return nil, err
}
return cfg, nil
}
// putSnapshotConfig stores snapshot config to S3
func (s *SnapshotServiceImpl) putSnapshotConfig(snapshotID string, cfg *SnapshotConfig) error {
return WriteSnapshotConfig(s.store, s.config.Predastore.Bucket, snapshotID, cfg)
}
// snapshotConfigToEC2 converts a SnapshotConfig to an EC2 Snapshot response object
func snapshotConfigToEC2(cfg *SnapshotConfig) *ec2.Snapshot {
snapshot := &ec2.Snapshot{