mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 17:11:30 +00:00
Merge commit '0a7832440504442f685e85048d545848a8f2b8f6' into feat/iso-installer
This commit is contained in:
+6
-6
@@ -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
|
||||
|
||||
|
||||
@@ -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"},
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user