mirror of
https://github.com/mulgadc/spinifex.git
synced 2026-10-15 09:01:31 +00:00
feat(ec2): EC2 Launch Templates (#498)
This commit is contained in:
+8
-6
@@ -178,7 +178,7 @@ read.
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `run-instances` | `--image-id`, `--instance-type`, `--count`, `--key-name`, `--user-data`, `--subnet-id`, `--security-group-ids`, `--tag-specifications` (instance-scoped), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName), `--iam-instance-profile` (Name/Arn), `--capacity-reservation-specification` (CapacityReservationTarget.CapacityReservationId, targeted-by-id only), `--metadata-options` (HttpPutResponseHopLimit; IMDSv2-only enforced — rejects `http-tokens=optional`) | `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--launch-template`, `--hibernate-options` | **DONE** |
|
||||
| `run-instances` | `--image-id`, `--instance-type`, `--count`, `--key-name`, `--user-data`, `--subnet-id`, `--security-group-ids`, `--tag-specifications` (instance-scoped), `--block-device-mappings` (DeviceName, VolumeSize, VolumeType, Iops, DeleteOnTermination), `--placement` (GroupName), `--iam-instance-profile` (Name/Arn), `--capacity-reservation-specification` (CapacityReservationTarget.CapacityReservationId, targeted-by-id only), `--metadata-options` (HttpPutResponseHopLimit; IMDSv2-only enforced — rejects `http-tokens=optional`), `--launch-template` (LaunchTemplateId/LaunchTemplateName, Version — resolves `$Default`/`$Latest`; direct params override the template) | `--dry-run`, `--client-token`, `--disable-api-termination`, `--ebs-optimized`, `--network-interfaces`, `--private-ip-address`, `--monitoring`, `--credit-specification`, `--cpu-options`, `--hibernate-options` | **DONE** |
|
||||
| `describe-instances` | `--instance-ids`, `--filters` (instance-state-name, instance-id, instance-type, vpc-id, subnet-id, tag:*, tag-key, tag-value) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
|
||||
| `start-instances` | `--instance-ids` | `--dry-run`, `--force` | **DONE** |
|
||||
| `stop-instances` | `--instance-ids` | `--force`, `--hibernate`, `--dry-run` | **DONE** |
|
||||
@@ -480,11 +480,13 @@ EIP handlers are always registered. Without a public IPAM pool (external mode di
|
||||
|
||||
| Command | Implemented Flags | Missing Flags | Status |
|
||||
|---------|-------------------|---------------|--------|
|
||||
| `create-launch-template` | — | `--launch-template-name`, `--launch-template-data`, `--tag-specifications` | **NOT STARTED** |
|
||||
| `create-launch-template-version` | — | `--launch-template-id`/`--launch-template-name`, `--launch-template-data`, `--source-version` | **NOT STARTED** |
|
||||
| `delete-launch-template` | — | `--launch-template-id`/`--launch-template-name`, `--dry-run` | **NOT STARTED** |
|
||||
| `describe-launch-templates` | — | `--launch-template-ids`, `--launch-template-names`, `--filters` | **NOT STARTED** |
|
||||
| `describe-launch-template-versions` | — | `--launch-template-id`/`--launch-template-name`, `--versions`, `--min-version`, `--max-version` | **NOT STARTED** |
|
||||
| `create-launch-template` | `--launch-template-name`, `--launch-template-data` (full nested RequestLaunchTemplateData), `--version-description`, `--tag-specifications` (launch-template-scoped), `--dry-run` (no-op) | `--client-token` (idempotency) | **DONE** |
|
||||
| `create-launch-template-version` | `--launch-template-id`/`--launch-template-name`, `--launch-template-data`, `--source-version` (clone-and-override), `--version-description`, `--dry-run` (no-op) | `--client-token`, `--resolve-alias` | **DONE** |
|
||||
| `delete-launch-template` | `--launch-template-id`/`--launch-template-name`, `--dry-run` (no-op) | — | **DONE** |
|
||||
| `delete-launch-template-versions` | `--launch-template-id`/`--launch-template-name`, `--versions` (rejects the current default version), `--dry-run` (no-op) | — | **DONE** |
|
||||
| `modify-launch-template` | `--launch-template-id`/`--launch-template-name`, `--default-version`, `--dry-run` (no-op) | — | **DONE** |
|
||||
| `describe-launch-templates` | `--launch-template-ids`, `--launch-template-names`, `--filters` (launch-template-id, launch-template-name, create-time, tag:*, tag-key) | `--max-results`, `--next-token`, `--dry-run` | **DONE** |
|
||||
| `describe-launch-template-versions` | `--launch-template-id`/`--launch-template-name`, `--versions` (`$Default`/`$Latest`/numeric), `--min-version`, `--max-version`, `--filters` (is-default-version, image-id, instance-type, kernel-id, ram-disk-id, ebs-optimized) | `--max-results`, `--next-token`, `--dry-run`, `--resolve-alias` | **DONE** |
|
||||
|
||||
### EC2 — Dedicated Hosts, IPv4 Pools, DHCP, Capacity Reservations
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ErrSliceTooLarge is returned when a list exceeds maxSliceLen entries.
|
||||
@@ -160,6 +161,16 @@ func setFieldValue(field reflect.Value, value string) error {
|
||||
return err
|
||||
}
|
||||
field.SetBool(b)
|
||||
case reflect.Struct:
|
||||
if field.Type() == reflect.TypeFor[time.Time]() {
|
||||
timestamp, err := time.Parse(time.RFC3339, value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("parse RFC3339 timestamp: %w", err)
|
||||
}
|
||||
field.Set(reflect.ValueOf(timestamp))
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("unsupported struct type: %v", field.Type())
|
||||
default:
|
||||
return fmt.Errorf("unsupported field type: %v", field.Kind())
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package awsec2query
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
@@ -649,3 +650,27 @@ func TestQueryParamsToStruct_SparseHugeIndexNoOp(t *testing.T) {
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, input.Filters)
|
||||
}
|
||||
|
||||
func TestQueryParamsToStruct_CreateLaunchTemplateNestedData(t *testing.T) {
|
||||
args := map[string]string{
|
||||
"LaunchTemplateName": "web-template",
|
||||
"LaunchTemplateData.ImageId": "ami-0abcdef1234567890",
|
||||
"LaunchTemplateData.InstanceType": "t3.micro",
|
||||
"LaunchTemplateData.BlockDeviceMapping.1.DeviceName": "/dev/sda1",
|
||||
"LaunchTemplateData.BlockDeviceMapping.1.Ebs.VolumeSize": "20",
|
||||
"LaunchTemplateData.InstanceMarketOptions.SpotOptions.ValidUntil": "2026-07-10T12:00:00Z",
|
||||
}
|
||||
|
||||
input := &ec2.CreateLaunchTemplateInput{}
|
||||
err := QueryParamsToStruct(args, input)
|
||||
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, input.LaunchTemplateData)
|
||||
assert.Equal(t, "ami-0abcdef1234567890", aws.StringValue(input.LaunchTemplateData.ImageId))
|
||||
require.Len(t, input.LaunchTemplateData.BlockDeviceMappings, 1)
|
||||
assert.Equal(t, int64(20), aws.Int64Value(input.LaunchTemplateData.BlockDeviceMappings[0].Ebs.VolumeSize))
|
||||
require.NotNil(t, input.LaunchTemplateData.InstanceMarketOptions)
|
||||
require.NotNil(t, input.LaunchTemplateData.InstanceMarketOptions.SpotOptions)
|
||||
assert.Equal(t, time.Date(2026, time.July, 10, 12, 0, 0, 0, time.UTC),
|
||||
*input.LaunchTemplateData.InstanceMarketOptions.SpotOptions.ValidUntil)
|
||||
}
|
||||
|
||||
@@ -41,6 +41,7 @@ import (
|
||||
handlers_ec2_image "github.com/mulgadc/spinifex/spinifex/handlers/ec2/image"
|
||||
handlers_ec2_instance "github.com/mulgadc/spinifex/spinifex/handlers/ec2/instance"
|
||||
handlers_ec2_key "github.com/mulgadc/spinifex/spinifex/handlers/ec2/key"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
handlers_ec2_natgw "github.com/mulgadc/spinifex/spinifex/handlers/ec2/natgw"
|
||||
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
|
||||
handlers_ec2_routetable "github.com/mulgadc/spinifex/spinifex/handlers/ec2/routetable"
|
||||
@@ -132,6 +133,7 @@ type Daemon struct {
|
||||
eigwService *handlers_ec2_eigw.EgressOnlyIGWServiceImpl
|
||||
igwService *handlers_ec2_igw.IGWServiceImpl
|
||||
placementGroupService *handlers_ec2_placementgroup.PlacementGroupServiceImpl
|
||||
launchTemplateService *handlers_ec2_launchtemplate.LaunchTemplateServiceImpl
|
||||
spotInstanceService *handlers_ec2_spotinstance.SpotInstanceServiceImpl
|
||||
vpcService *handlers_ec2_vpc.VPCServiceImpl
|
||||
eipService handlers_ec2_eip.EIPService
|
||||
@@ -806,6 +808,13 @@ func (d *Daemon) subscribeAll() error {
|
||||
{"ec2.RemoveInstanceFromPlacementGroup", d.handleEC2RemoveInstanceFromPlacementGroup, "spinifex-workers"},
|
||||
{"ec2.ReserveClusterNode", d.handleEC2ReserveClusterNode, "spinifex-workers"},
|
||||
{"ec2.FinalizeClusterInstances", d.handleEC2FinalizeClusterInstances, "spinifex-workers"},
|
||||
{"ec2.CreateLaunchTemplate", d.handleEC2CreateLaunchTemplate, "spinifex-workers"},
|
||||
{"ec2.CreateLaunchTemplateVersion", d.handleEC2CreateLaunchTemplateVersion, "spinifex-workers"},
|
||||
{"ec2.DeleteLaunchTemplate", d.handleEC2DeleteLaunchTemplate, "spinifex-workers"},
|
||||
{"ec2.DeleteLaunchTemplateVersions", d.handleEC2DeleteLaunchTemplateVersions, "spinifex-workers"},
|
||||
{"ec2.ModifyLaunchTemplate", d.handleEC2ModifyLaunchTemplate, "spinifex-workers"},
|
||||
{"ec2.DescribeLaunchTemplates", d.handleEC2DescribeLaunchTemplates, "spinifex-workers"},
|
||||
{"ec2.DescribeLaunchTemplateVersions", d.handleEC2DescribeLaunchTemplateVersions, "spinifex-workers"},
|
||||
{"ec2.PutSpotInstanceRequests", d.handleEC2PutSpotInstanceRequests, "spinifex-workers"},
|
||||
{"ec2.DescribeSpotInstanceRequests", d.handleEC2DescribeSpotInstanceRequests, "spinifex-workers"},
|
||||
{"ec2.CancelSpotInstanceRequests", d.handleEC2CancelSpotInstanceRequests, "spinifex-workers"},
|
||||
@@ -1218,6 +1227,8 @@ func (d *Daemon) assertNoClusterServicesInitialised() error {
|
||||
return errors.New("d.igwService must be nil before startCluster")
|
||||
case d.placementGroupService != nil:
|
||||
return errors.New("d.placementGroupService must be nil before startCluster")
|
||||
case d.launchTemplateService != nil:
|
||||
return errors.New("d.launchTemplateService must be nil before startCluster")
|
||||
case d.spotInstanceService != nil:
|
||||
return errors.New("d.spotInstanceService must be nil before startCluster")
|
||||
case d.vpcService != nil:
|
||||
@@ -1331,6 +1342,13 @@ func (d *Daemon) startCluster() error {
|
||||
return fmt.Errorf("failed to initialize placement group service: %w", err)
|
||||
}
|
||||
|
||||
d.launchTemplateService, err = initServiceWithRetry("launch template service", func() (*handlers_ec2_launchtemplate.LaunchTemplateServiceImpl, error) {
|
||||
return handlers_ec2_launchtemplate.NewLaunchTemplateServiceImplWithNATS(d.config, d.natsConn)
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to initialize launch template service: %w", err)
|
||||
}
|
||||
|
||||
d.spotInstanceService, err = initServiceWithRetry("spot instance service", func() (*handlers_ec2_spotinstance.SpotInstanceServiceImpl, error) {
|
||||
return handlers_ec2_spotinstance.NewSpotInstanceServiceImplWithNATS(d.config, d.natsConn)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
package daemon
|
||||
|
||||
import "github.com/nats-io/nats.go"
|
||||
|
||||
func (d *Daemon) handleEC2CreateLaunchTemplate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.CreateLaunchTemplate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2CreateLaunchTemplateVersion(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.CreateLaunchTemplateVersion)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteLaunchTemplate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.DeleteLaunchTemplate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DeleteLaunchTemplateVersions(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.DeleteLaunchTemplateVersions)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2ModifyLaunchTemplate(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.ModifyLaunchTemplate)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeLaunchTemplates(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.DescribeLaunchTemplates)
|
||||
}
|
||||
|
||||
func (d *Daemon) handleEC2DescribeLaunchTemplateVersions(msg *nats.Msg) {
|
||||
handleNATSRequest(msg, d.launchTemplateService.DescribeLaunchTemplateVersions)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
gateway_ec2_image "github.com/mulgadc/spinifex/spinifex/gateway/ec2/image"
|
||||
gateway_ec2_instance "github.com/mulgadc/spinifex/spinifex/gateway/ec2/instance"
|
||||
gateway_ec2_key "github.com/mulgadc/spinifex/spinifex/gateway/ec2/key"
|
||||
gateway_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/gateway/ec2/launchtemplate"
|
||||
gateway_ec2_natgw "github.com/mulgadc/spinifex/spinifex/gateway/ec2/natgw"
|
||||
gateway_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/gateway/ec2/placementgroup"
|
||||
gateway_ec2_routetable "github.com/mulgadc/spinifex/spinifex/gateway/ec2/routetable"
|
||||
@@ -353,6 +354,27 @@ var ec2Actions = map[string]EC2Handler{
|
||||
"CancelCapacityReservation": ec2Handler(func(ctx context.Context, input *ec2.CancelCapacityReservationInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_capacityreservation.CancelCapacityReservation(ctx, input, gw.NATSConn, gw.ExpectedNodes, accountID)
|
||||
}),
|
||||
"CreateLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.CreateLaunchTemplateInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_launchtemplate.CreateLaunchTemplate(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"CreateLaunchTemplateVersion": ec2Handler(func(ctx context.Context, input *ec2.CreateLaunchTemplateVersionInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_launchtemplate.CreateLaunchTemplateVersion(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_launchtemplate.DeleteLaunchTemplate(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DeleteLaunchTemplateVersions": ec2Handler(func(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_launchtemplate.DeleteLaunchTemplateVersions(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"ModifyLaunchTemplate": ec2Handler(func(ctx context.Context, input *ec2.ModifyLaunchTemplateInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_launchtemplate.ModifyLaunchTemplate(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeLaunchTemplates": ec2Handler(func(ctx context.Context, input *ec2.DescribeLaunchTemplatesInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_launchtemplate.DescribeLaunchTemplates(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"DescribeLaunchTemplateVersions": ec2Handler(func(ctx context.Context, input *ec2.DescribeLaunchTemplateVersionsInput, gw *GatewayConfig, accountID string) (any, error) {
|
||||
return gateway_ec2_launchtemplate.DescribeLaunchTemplateVersions(ctx, input, gw.NATSConn, accountID)
|
||||
}),
|
||||
"RequestSpotInstances": ec2HandlerWithReq(func(ctx context.Context, input *ec2.RequestSpotInstancesInput, gw *GatewayConfig, accountID string, r *http.Request) (any, error) {
|
||||
passRoleCheck := func(roleARN string) error {
|
||||
return gw.checkPolicyResource(r, "iam", "PassRole", roleARN)
|
||||
|
||||
@@ -10,6 +10,7 @@ import (
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
handlers_ec2_placementgroup "github.com/mulgadc/spinifex/spinifex/handlers/ec2/placementgroup"
|
||||
handlers_iam "github.com/mulgadc/spinifex/spinifex/handlers/iam"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
@@ -76,6 +77,12 @@ func ValidateRunInstancesInput(input *ec2.RunInstancesInput) (err error) {
|
||||
// launchQuotaCheck may be nil to skip quota enforcement; when present it runs
|
||||
// after validation and PassRole authorization, but before any launch dispatch.
|
||||
func RunInstances(ctx context.Context, input *ec2.RunInstancesInput, natsConn *nats.Conn, iamSvc handlers_iam.IAMService, accountID string, passRoleCheck PassRoleChecker, launchQuotaCheck LaunchQuotaChecker, expectedNodes int) (reservation ec2.Reservation, err error) {
|
||||
// Expand a referenced launch template before validation so validation, quota,
|
||||
// and downstream routing all see the effective (post-merge) parameters.
|
||||
if err = expandLaunchTemplate(ctx, natsConn, input, accountID); err != nil {
|
||||
return reservation, err
|
||||
}
|
||||
|
||||
if err = ValidateRunInstancesInput(input); err != nil {
|
||||
return reservation, err
|
||||
}
|
||||
@@ -236,6 +243,16 @@ func capacityReservationTargetID(input *ec2.RunInstancesInput) string {
|
||||
return aws.StringValue(spec.CapacityReservationTarget.CapacityReservationId)
|
||||
}
|
||||
|
||||
// expandLaunchTemplate folds a referenced launch template into input, delegating
|
||||
// to the launch-template service over NATS. A no-op when no template is referenced.
|
||||
func expandLaunchTemplate(ctx context.Context, natsConn *nats.Conn, input *ec2.RunInstancesInput, accountID string) error {
|
||||
if input == nil || input.LaunchTemplate == nil {
|
||||
return nil
|
||||
}
|
||||
ltSvc := handlers_ec2_launchtemplate.NewNATSLaunchTemplateService(natsConn)
|
||||
return handlers_ec2_launchtemplate.ExpandRunInstances(ctx, ltSvc, input, accountID)
|
||||
}
|
||||
|
||||
// lookupPlacementGroupStrategy returns the strategy of a placement group, or an error if absent/unavailable.
|
||||
func lookupPlacementGroupStrategy(ctx context.Context, natsConn *nats.Conn, accountID, groupName string) (string, error) {
|
||||
pgSvc := handlers_ec2_placementgroup.NewNATSPlacementGroupService(natsConn)
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateCreateLaunchTemplateInput checks the parameters required to create a
|
||||
// template. Name format and DryRun semantics are owned by the daemon.
|
||||
func ValidateCreateLaunchTemplateInput(input *ec2.CreateLaunchTemplateInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.LaunchTemplateData == nil {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if aws.StringValue(input.LaunchTemplateName) == "" {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CreateLaunchTemplate handles the EC2 CreateLaunchTemplate API call.
|
||||
func CreateLaunchTemplate(ctx context.Context, input *ec2.CreateLaunchTemplateInput, natsConn *nats.Conn, accountID string) (ec2.CreateLaunchTemplateOutput, error) {
|
||||
var output ec2.CreateLaunchTemplateOutput
|
||||
|
||||
if err := ValidateCreateLaunchTemplateInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_launchtemplate.NewNATSLaunchTemplateService(natsConn)
|
||||
result, err := svc.CreateLaunchTemplate(ctx, input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateCreateLaunchTemplateVersionInput checks the version data is present
|
||||
// and that exactly one of id/name addresses the parent template. SourceVersion
|
||||
// resolution is owned by the daemon.
|
||||
func ValidateCreateLaunchTemplateVersionInput(input *ec2.CreateLaunchTemplateVersionInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if input.LaunchTemplateData == nil {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return requireTemplateIdentity(input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
}
|
||||
|
||||
// CreateLaunchTemplateVersion handles the EC2 CreateLaunchTemplateVersion API call.
|
||||
func CreateLaunchTemplateVersion(ctx context.Context, input *ec2.CreateLaunchTemplateVersionInput, natsConn *nats.Conn, accountID string) (ec2.CreateLaunchTemplateVersionOutput, error) {
|
||||
var output ec2.CreateLaunchTemplateVersionOutput
|
||||
|
||||
if err := ValidateCreateLaunchTemplateVersionInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_launchtemplate.NewNATSLaunchTemplateService(natsConn)
|
||||
result, err := svc.CreateLaunchTemplateVersion(ctx, input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateDeleteLaunchTemplateInput requires exactly one of id/name.
|
||||
func ValidateDeleteLaunchTemplateInput(input *ec2.DeleteLaunchTemplateInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
return requireTemplateIdentity(input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
}
|
||||
|
||||
// DeleteLaunchTemplate handles the EC2 DeleteLaunchTemplate API call.
|
||||
func DeleteLaunchTemplate(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, natsConn *nats.Conn, accountID string) (ec2.DeleteLaunchTemplateOutput, error) {
|
||||
var output ec2.DeleteLaunchTemplateOutput
|
||||
|
||||
if err := ValidateDeleteLaunchTemplateInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_launchtemplate.NewNATSLaunchTemplateService(natsConn)
|
||||
result, err := svc.DeleteLaunchTemplate(ctx, input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateDeleteLaunchTemplateVersionsInput requires exactly one of id/name and
|
||||
// at least one version to delete. Per-version outcomes (default-version refusal,
|
||||
// missing versions) are reported by the daemon in the response.
|
||||
func ValidateDeleteLaunchTemplateVersionsInput(input *ec2.DeleteLaunchTemplateVersionsInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
if err := requireTemplateIdentity(input.LaunchTemplateId, input.LaunchTemplateName); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(input.Versions) == 0 {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteLaunchTemplateVersions handles the EC2 DeleteLaunchTemplateVersions API call.
|
||||
func DeleteLaunchTemplateVersions(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, natsConn *nats.Conn, accountID string) (ec2.DeleteLaunchTemplateVersionsOutput, error) {
|
||||
var output ec2.DeleteLaunchTemplateVersionsOutput
|
||||
|
||||
if err := ValidateDeleteLaunchTemplateVersionsInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_launchtemplate.NewNATSLaunchTemplateService(natsConn)
|
||||
result, err := svc.DeleteLaunchTemplateVersions(ctx, input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateDescribeLaunchTemplateVersionsInput requires exactly one of id/name.
|
||||
// Version selection ($Default/$Latest/numeric, Min/Max range) is owned by the
|
||||
// daemon.
|
||||
func ValidateDescribeLaunchTemplateVersionsInput(input *ec2.DescribeLaunchTemplateVersionsInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
return requireTemplateIdentity(input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
}
|
||||
|
||||
// DescribeLaunchTemplateVersions handles the EC2 DescribeLaunchTemplateVersions API call.
|
||||
func DescribeLaunchTemplateVersions(ctx context.Context, input *ec2.DescribeLaunchTemplateVersionsInput, natsConn *nats.Conn, accountID string) (ec2.DescribeLaunchTemplateVersionsOutput, error) {
|
||||
var output ec2.DescribeLaunchTemplateVersionsOutput
|
||||
|
||||
if err := ValidateDescribeLaunchTemplateVersionsInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_launchtemplate.NewNATSLaunchTemplateService(natsConn)
|
||||
result, err := svc.DescribeLaunchTemplateVersions(ctx, input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// DescribeLaunchTemplates handles the EC2 DescribeLaunchTemplates API call. No
|
||||
// identity is required (describe-all is valid); id/name/filter matching is owned
|
||||
// by the daemon.
|
||||
func DescribeLaunchTemplates(ctx context.Context, input *ec2.DescribeLaunchTemplatesInput, natsConn *nats.Conn, accountID string) (ec2.DescribeLaunchTemplatesOutput, error) {
|
||||
var output ec2.DescribeLaunchTemplatesOutput
|
||||
|
||||
svc := handlers_ec2_launchtemplate.NewNATSLaunchTemplateService(natsConn)
|
||||
result, err := svc.DescribeLaunchTemplates(ctx, input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
handlers_ec2_launchtemplate "github.com/mulgadc/spinifex/spinifex/handlers/ec2/launchtemplate"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// ValidateModifyLaunchTemplateInput requires exactly one of id/name. DefaultVersion
|
||||
// resolution (the only mutable field) is owned by the daemon.
|
||||
func ValidateModifyLaunchTemplateInput(input *ec2.ModifyLaunchTemplateInput) error {
|
||||
if input == nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
return requireTemplateIdentity(input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
}
|
||||
|
||||
// ModifyLaunchTemplate handles the EC2 ModifyLaunchTemplate API call.
|
||||
func ModifyLaunchTemplate(ctx context.Context, input *ec2.ModifyLaunchTemplateInput, natsConn *nats.Conn, accountID string) (ec2.ModifyLaunchTemplateOutput, error) {
|
||||
var output ec2.ModifyLaunchTemplateOutput
|
||||
|
||||
if err := ValidateModifyLaunchTemplateInput(input); err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
svc := handlers_ec2_launchtemplate.NewNATSLaunchTemplateService(natsConn)
|
||||
result, err := svc.ModifyLaunchTemplate(ctx, input, accountID)
|
||||
if err != nil {
|
||||
return output, err
|
||||
}
|
||||
|
||||
return *result, nil
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 testAccountID = "123456789012"
|
||||
|
||||
func validData() *ec2.RequestLaunchTemplateData {
|
||||
return &ec2.RequestLaunchTemplateData{
|
||||
ImageId: aws.String("ami-123"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
}
|
||||
}
|
||||
|
||||
// requireTemplateIdentity
|
||||
|
||||
func TestRequireTemplateIdentity(t *testing.T) {
|
||||
assert.EqualError(t, requireTemplateIdentity(nil, nil), awserrors.ErrorMissingParameter)
|
||||
assert.EqualError(t, requireTemplateIdentity(aws.String(""), aws.String("")), awserrors.ErrorMissingParameter)
|
||||
assert.EqualError(t, requireTemplateIdentity(aws.String("lt-1"), aws.String("name")), awserrors.ErrorInvalidParameterValue)
|
||||
assert.NoError(t, requireTemplateIdentity(aws.String("lt-1"), nil))
|
||||
assert.NoError(t, requireTemplateIdentity(nil, aws.String("name")))
|
||||
}
|
||||
|
||||
// CreateLaunchTemplate
|
||||
|
||||
func TestCreateLaunchTemplate_NilInput(t *testing.T) {
|
||||
_, err := CreateLaunchTemplate(context.Background(), nil, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplate_MissingData(t *testing.T) {
|
||||
_, err := CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplate_MissingName(t *testing.T) {
|
||||
_, err := CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateData: validData(),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
// CreateLaunchTemplateVersion
|
||||
|
||||
func TestCreateLaunchTemplateVersion_MissingData(t *testing.T) {
|
||||
_, err := CreateLaunchTemplateVersion(context.Background(), &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String("lt-1"),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplateVersion_BothIdAndName(t *testing.T) {
|
||||
_, err := CreateLaunchTemplateVersion(context.Background(), &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateData: validData(),
|
||||
LaunchTemplateId: aws.String("lt-1"),
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
// DeleteLaunchTemplate
|
||||
|
||||
func TestDeleteLaunchTemplate_MissingIdentity(t *testing.T) {
|
||||
_, err := DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
// DeleteLaunchTemplateVersions
|
||||
|
||||
func TestDeleteLaunchTemplateVersions_MissingVersions(t *testing.T) {
|
||||
_, err := DeleteLaunchTemplateVersions(context.Background(), &ec2.DeleteLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String("lt-1"),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
func TestDeleteLaunchTemplateVersions_MissingIdentity(t *testing.T) {
|
||||
_, err := DeleteLaunchTemplateVersions(context.Background(), &ec2.DeleteLaunchTemplateVersionsInput{
|
||||
Versions: aws.StringSlice([]string{"1"}),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
// ModifyLaunchTemplate
|
||||
|
||||
func TestModifyLaunchTemplate_MissingIdentity(t *testing.T) {
|
||||
_, err := ModifyLaunchTemplate(context.Background(), &ec2.ModifyLaunchTemplateInput{
|
||||
DefaultVersion: aws.String("2"),
|
||||
}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
// DescribeLaunchTemplateVersions
|
||||
|
||||
func TestDescribeLaunchTemplateVersions_MissingIdentity(t *testing.T) {
|
||||
_, err := DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{}, nil, testAccountID)
|
||||
assert.EqualError(t, err, awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
// Forward path: valid input with a nil NATS conn reaches the service client,
|
||||
// which fails cluster-unavailable. Exercises the validate-then-forward wiring.
|
||||
|
||||
func TestForwardPath_NilNATS(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
tests := []struct {
|
||||
name string
|
||||
call func() error
|
||||
}{
|
||||
{"CreateLaunchTemplate", func() error {
|
||||
_, err := CreateLaunchTemplate(ctx, &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("web"), LaunchTemplateData: validData(),
|
||||
}, nil, testAccountID)
|
||||
return err
|
||||
}},
|
||||
{"CreateLaunchTemplateVersion", func() error {
|
||||
_, err := CreateLaunchTemplateVersion(ctx, &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String("lt-1"), LaunchTemplateData: validData(),
|
||||
}, nil, testAccountID)
|
||||
return err
|
||||
}},
|
||||
{"DeleteLaunchTemplate", func() error {
|
||||
_, err := DeleteLaunchTemplate(ctx, &ec2.DeleteLaunchTemplateInput{
|
||||
LaunchTemplateId: aws.String("lt-1"),
|
||||
}, nil, testAccountID)
|
||||
return err
|
||||
}},
|
||||
{"DeleteLaunchTemplateVersions", func() error {
|
||||
_, err := DeleteLaunchTemplateVersions(ctx, &ec2.DeleteLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String("lt-1"), Versions: aws.StringSlice([]string{"1"}),
|
||||
}, nil, testAccountID)
|
||||
return err
|
||||
}},
|
||||
{"ModifyLaunchTemplate", func() error {
|
||||
_, err := ModifyLaunchTemplate(ctx, &ec2.ModifyLaunchTemplateInput{
|
||||
LaunchTemplateId: aws.String("lt-1"), DefaultVersion: aws.String("2"),
|
||||
}, nil, testAccountID)
|
||||
return err
|
||||
}},
|
||||
{"DescribeLaunchTemplates", func() error {
|
||||
_, err := DescribeLaunchTemplates(ctx, &ec2.DescribeLaunchTemplatesInput{}, nil, testAccountID)
|
||||
return err
|
||||
}},
|
||||
{"DescribeLaunchTemplateVersions", func() error {
|
||||
_, err := DescribeLaunchTemplateVersions(ctx, &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String("lt-1"),
|
||||
}, nil, testAccountID)
|
||||
return err
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
assert.Error(t, tt.call())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
package gateway_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
)
|
||||
|
||||
// requireTemplateIdentity enforces the id-XOR-name rule shared by the actions
|
||||
// that address an existing template. Both set is invalid, neither is a missing
|
||||
// parameter. Error codes match the daemon-side resolver so behaviour is
|
||||
// identical whether or not the request reaches the daemon.
|
||||
func requireTemplateIdentity(id, name *string) error {
|
||||
idSet := aws.StringValue(id) != ""
|
||||
nameSet := aws.StringValue(name) != ""
|
||||
switch {
|
||||
case idSet && nameSet:
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
case !idSet && !nameSet:
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package handlers_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
)
|
||||
|
||||
// ExpandRunInstances folds a referenced launch template into input before it is
|
||||
// validated: it resolves the template version, maps its stored data to a
|
||||
// RunInstancesInput base, then overlays the direct RunInstances parameters
|
||||
// (whole-field, presence-based — direct params win, nil inherits the template).
|
||||
// MinCount/MaxCount are never sourced from a template. input.LaunchTemplate is
|
||||
// cleared so the downstream launch path is untouched. It is a no-op when no
|
||||
// template is referenced.
|
||||
func ExpandRunInstances(ctx context.Context, svc LaunchTemplateService, input *ec2.RunInstancesInput, accountID string) error {
|
||||
if input == nil || input.LaunchTemplate == nil {
|
||||
return nil
|
||||
}
|
||||
spec := input.LaunchTemplate
|
||||
|
||||
id := aws.StringValue(spec.LaunchTemplateId)
|
||||
name := aws.StringValue(spec.LaunchTemplateName)
|
||||
switch {
|
||||
case id != "" && name != "":
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
case id == "" && name == "":
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
// Version defaults to $Default; DescribeLaunchTemplateVersions resolves the
|
||||
// alias and surfaces a missing body as VersionNotFound.
|
||||
version := aws.StringValue(spec.Version)
|
||||
if version == "" {
|
||||
version = versionDefault
|
||||
}
|
||||
descIn := &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
Versions: []*string{aws.String(version)},
|
||||
}
|
||||
if id != "" {
|
||||
descIn.LaunchTemplateId = aws.String(id)
|
||||
} else {
|
||||
descIn.LaunchTemplateName = aws.String(name)
|
||||
}
|
||||
|
||||
out, err := svc.DescribeLaunchTemplateVersions(ctx, descIn, accountID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(out.LaunchTemplateVersions) == 0 || out.LaunchTemplateVersions[0].LaunchTemplateData == nil {
|
||||
return errors.New(awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound)
|
||||
}
|
||||
|
||||
base, err := responseToRunInstances(out.LaunchTemplateVersions[0].LaunchTemplateData)
|
||||
if err != nil {
|
||||
return errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
merged := mergeRunInstancesInput(base, input)
|
||||
merged.LaunchTemplate = nil
|
||||
*input = *merged
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
package handlers_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"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 TestExpandRunInstances_NoTemplate(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
input := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-direct"),
|
||||
InstanceType: aws.String("t3.nano"),
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
}
|
||||
require.NoError(t, ExpandRunInstances(context.Background(), svc, input, testAccountID))
|
||||
assert.Equal(t, "ami-direct", aws.StringValue(input.ImageId), "untouched when no template referenced")
|
||||
assert.Equal(t, "t3.nano", aws.StringValue(input.InstanceType))
|
||||
}
|
||||
|
||||
func TestExpandRunInstances_ByIdDefaultVersion(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro") // v1: ami-123, t3.micro
|
||||
input := &ec2.RunInstancesInput{
|
||||
MinCount: aws.Int64(2),
|
||||
MaxCount: aws.Int64(3),
|
||||
LaunchTemplate: &ec2.LaunchTemplateSpecification{LaunchTemplateId: lt.LaunchTemplateId},
|
||||
}
|
||||
require.NoError(t, ExpandRunInstances(context.Background(), svc, input, testAccountID))
|
||||
assert.Equal(t, "ami-123", aws.StringValue(input.ImageId), "template image applied")
|
||||
assert.Equal(t, "t3.micro", aws.StringValue(input.InstanceType), "template type applied")
|
||||
assert.Nil(t, input.LaunchTemplate, "template spec cleared after expansion")
|
||||
assert.Equal(t, int64(2), aws.Int64Value(input.MinCount), "counts come from the direct request")
|
||||
assert.Equal(t, int64(3), aws.Int64Value(input.MaxCount))
|
||||
}
|
||||
|
||||
func TestExpandRunInstances_DirectParamsWin(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTemplate(t, svc, "web", "t3.micro")
|
||||
input := &ec2.RunInstancesInput{
|
||||
InstanceType: aws.String("t3.large"), // overrides template
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
LaunchTemplate: &ec2.LaunchTemplateSpecification{LaunchTemplateName: aws.String("web")},
|
||||
}
|
||||
require.NoError(t, ExpandRunInstances(context.Background(), svc, input, testAccountID))
|
||||
assert.Equal(t, "t3.large", aws.StringValue(input.InstanceType), "direct param overrides template")
|
||||
assert.Equal(t, "ami-123", aws.StringValue(input.ImageId), "template image inherited")
|
||||
}
|
||||
|
||||
func TestExpandRunInstances_SpecificVersion(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
addVersion(t, svc, id, "t3.large", "") // v2: no SourceVersion, so image is unset
|
||||
|
||||
input := &ec2.RunInstancesInput{
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
LaunchTemplate: &ec2.LaunchTemplateSpecification{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
Version: aws.String("2"),
|
||||
},
|
||||
}
|
||||
require.NoError(t, ExpandRunInstances(context.Background(), svc, input, testAccountID))
|
||||
assert.Equal(t, "t3.large", aws.StringValue(input.InstanceType))
|
||||
assert.Nil(t, input.ImageId, "v2 carried no image")
|
||||
}
|
||||
|
||||
func TestExpandRunInstances_MissingVersion(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
input := &ec2.RunInstancesInput{
|
||||
LaunchTemplate: &ec2.LaunchTemplateSpecification{
|
||||
LaunchTemplateId: lt.LaunchTemplateId,
|
||||
Version: aws.String("99"),
|
||||
},
|
||||
}
|
||||
err := ExpandRunInstances(context.Background(), svc, input, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, err.Error())
|
||||
}
|
||||
|
||||
func TestExpandRunInstances_IdNameConflict(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
input := &ec2.RunInstancesInput{
|
||||
LaunchTemplate: &ec2.LaunchTemplateSpecification{
|
||||
LaunchTemplateId: aws.String("lt-123"),
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
},
|
||||
}
|
||||
err := ExpandRunInstances(context.Background(), svc, input, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
|
||||
func TestExpandRunInstances_NeitherIdNorName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
input := &ec2.RunInstancesInput{LaunchTemplate: &ec2.LaunchTemplateSpecification{}}
|
||||
err := ExpandRunInstances(context.Background(), svc, input, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
|
||||
}
|
||||
|
||||
func TestExpandRunInstances_UnknownTemplate(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
input := &ec2.RunInstancesInput{
|
||||
LaunchTemplate: &ec2.LaunchTemplateSpecification{LaunchTemplateId: aws.String("lt-doesnotexist000")},
|
||||
}
|
||||
err := ExpandRunInstances(context.Background(), svc, input, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, err.Error())
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
package handlers_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"reflect"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
)
|
||||
|
||||
// The launch-template type families (RequestLaunchTemplateData,
|
||||
// ResponseLaunchTemplateData, RunInstancesInput) share top-level field names but
|
||||
// use different nested Go types. The SDK structs carry no json tags, so a json
|
||||
// round-trip keys off Go field names and transfers every identically-named field,
|
||||
// preserving nil vs non-nil-empty slices. The only names that diverge, and thus
|
||||
// must be handled explicitly, are enumerated in responseToRunInstances below.
|
||||
|
||||
// requestToResponse converts create-time RequestLaunchTemplateData into the stored
|
||||
// ResponseLaunchTemplateData. The two families have identical field-name sets, so
|
||||
// the round-trip is lossless.
|
||||
func requestToResponse(in *ec2.RequestLaunchTemplateData) (*ec2.ResponseLaunchTemplateData, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal RequestLaunchTemplateData: %w", err)
|
||||
}
|
||||
out := &ec2.ResponseLaunchTemplateData{}
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal into ResponseLaunchTemplateData: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// responseToRunInstances expands a stored template version into a RunInstancesInput.
|
||||
// The round-trip handles every identically-named field; the three fields whose Go
|
||||
// names diverge from RunInstancesInput are handled explicitly: RamDiskId->RamdiskId
|
||||
// and ElasticGpuSpecifications->ElasticGpuSpecification are remapped, and
|
||||
// InstanceRequirements (no RunInstances equivalent) is intentionally dropped.
|
||||
// MinCount/MaxCount are never sourced from a template.
|
||||
func responseToRunInstances(in *ec2.ResponseLaunchTemplateData) (*ec2.RunInstancesInput, error) {
|
||||
if in == nil {
|
||||
return nil, nil
|
||||
}
|
||||
b, err := json.Marshal(in)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal ResponseLaunchTemplateData: %w", err)
|
||||
}
|
||||
out := &ec2.RunInstancesInput{}
|
||||
if err := json.Unmarshal(b, out); err != nil {
|
||||
return nil, fmt.Errorf("unmarshal into RunInstancesInput: %w", err)
|
||||
}
|
||||
out.RamdiskId = in.RamDiskId
|
||||
if in.ElasticGpuSpecifications != nil {
|
||||
out.ElasticGpuSpecification = make([]*ec2.ElasticGpuSpecification, len(in.ElasticGpuSpecifications))
|
||||
for i, e := range in.ElasticGpuSpecifications {
|
||||
if e != nil {
|
||||
out.ElasticGpuSpecification[i] = &ec2.ElasticGpuSpecification{Type: e.Type}
|
||||
}
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
// mergeResponseData overlays override onto base with whole-field, presence-based
|
||||
// replace (used by CreateLaunchTemplateVersion's SourceVersion clone-and-override).
|
||||
func mergeResponseData(base, override *ec2.ResponseLaunchTemplateData) *ec2.ResponseLaunchTemplateData {
|
||||
if base == nil {
|
||||
base = &ec2.ResponseLaunchTemplateData{}
|
||||
}
|
||||
out := *base
|
||||
if override != nil {
|
||||
mergeWholeField(&out, override)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// mergeRunInstancesInput overlays direct RunInstances params (override) onto a
|
||||
// template-derived base. Because a template never carries MinCount/MaxCount/
|
||||
// ClientToken/DryRun/LaunchTemplate, those come from the direct request alone.
|
||||
func mergeRunInstancesInput(base, override *ec2.RunInstancesInput) *ec2.RunInstancesInput {
|
||||
if base == nil {
|
||||
base = &ec2.RunInstancesInput{}
|
||||
}
|
||||
out := *base
|
||||
if override != nil {
|
||||
mergeWholeField(&out, override)
|
||||
}
|
||||
return &out
|
||||
}
|
||||
|
||||
// mergeWholeField overlays override onto base in place: for each exported field a
|
||||
// "present" override value (non-nil pointer/slice/map, incl. a non-nil empty slice
|
||||
// that honours an explicit clear) replaces the base field entirely; a nil override
|
||||
// field inherits base. base and override must be non-nil pointers to the same
|
||||
// struct type, so there is no field-name drift risk.
|
||||
func mergeWholeField(base, override any) {
|
||||
bv := reflect.ValueOf(base).Elem()
|
||||
ov := reflect.ValueOf(override).Elem()
|
||||
for i := 0; i < ov.NumField(); i++ {
|
||||
f := ov.Field(i)
|
||||
if !bv.Field(i).CanSet() {
|
||||
continue
|
||||
}
|
||||
switch f.Kind() {
|
||||
case reflect.Pointer, reflect.Slice, reflect.Map, reflect.Interface:
|
||||
if !f.IsNil() {
|
||||
bv.Field(i).Set(f)
|
||||
}
|
||||
default:
|
||||
if !f.IsZero() {
|
||||
bv.Field(i).Set(f)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,284 @@
|
||||
package handlers_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// fullRequestData returns a RequestLaunchTemplateData with every top-level field
|
||||
// populated non-nil, covering all nested sub-types. It is the fixture for the
|
||||
// drift guard: if the SDK gains a field, assertNoNilPointerFields flags the gap.
|
||||
func fullRequestData() *ec2.RequestLaunchTemplateData {
|
||||
return &ec2.RequestLaunchTemplateData{
|
||||
BlockDeviceMappings: []*ec2.LaunchTemplateBlockDeviceMappingRequest{{
|
||||
DeviceName: aws.String("/dev/sda1"),
|
||||
NoDevice: aws.String(""),
|
||||
VirtualName: aws.String("ephemeral0"),
|
||||
Ebs: &ec2.LaunchTemplateEbsBlockDeviceRequest{
|
||||
DeleteOnTermination: aws.Bool(true),
|
||||
Encrypted: aws.Bool(true),
|
||||
Iops: aws.Int64(3000),
|
||||
KmsKeyId: aws.String("kms-1"),
|
||||
SnapshotId: aws.String("snap-1"),
|
||||
Throughput: aws.Int64(125),
|
||||
VolumeSize: aws.Int64(20),
|
||||
VolumeType: aws.String("gp3"),
|
||||
},
|
||||
}},
|
||||
CapacityReservationSpecification: &ec2.LaunchTemplateCapacityReservationSpecificationRequest{
|
||||
CapacityReservationPreference: aws.String("open"),
|
||||
CapacityReservationTarget: &ec2.CapacityReservationTarget{
|
||||
CapacityReservationId: aws.String("cr-1"),
|
||||
CapacityReservationResourceGroupArn: aws.String("arn:crg"),
|
||||
},
|
||||
},
|
||||
CpuOptions: &ec2.LaunchTemplateCpuOptionsRequest{
|
||||
AmdSevSnp: aws.String("enabled"),
|
||||
CoreCount: aws.Int64(2),
|
||||
ThreadsPerCore: aws.Int64(1),
|
||||
},
|
||||
CreditSpecification: &ec2.CreditSpecificationRequest{CpuCredits: aws.String("standard")},
|
||||
DisableApiStop: aws.Bool(true),
|
||||
DisableApiTermination: aws.Bool(true),
|
||||
EbsOptimized: aws.Bool(true),
|
||||
ElasticGpuSpecifications: []*ec2.ElasticGpuSpecification{{
|
||||
Type: aws.String("eg1.medium"),
|
||||
}},
|
||||
ElasticInferenceAccelerators: []*ec2.LaunchTemplateElasticInferenceAccelerator{{
|
||||
Count: aws.Int64(1),
|
||||
Type: aws.String("eia2.medium"),
|
||||
}},
|
||||
EnclaveOptions: &ec2.LaunchTemplateEnclaveOptionsRequest{Enabled: aws.Bool(true)},
|
||||
HibernationOptions: &ec2.LaunchTemplateHibernationOptionsRequest{Configured: aws.Bool(true)},
|
||||
IamInstanceProfile: &ec2.LaunchTemplateIamInstanceProfileSpecificationRequest{Arn: aws.String("arn:iam"), Name: aws.String("role")},
|
||||
ImageId: aws.String("ami-123"),
|
||||
InstanceInitiatedShutdownBehavior: aws.String("stop"),
|
||||
InstanceMarketOptions: &ec2.LaunchTemplateInstanceMarketOptionsRequest{
|
||||
MarketType: aws.String("spot"),
|
||||
SpotOptions: &ec2.LaunchTemplateSpotMarketOptionsRequest{
|
||||
BlockDurationMinutes: aws.Int64(60),
|
||||
InstanceInterruptionBehavior: aws.String("terminate"),
|
||||
MaxPrice: aws.String("0.05"),
|
||||
SpotInstanceType: aws.String("one-time"),
|
||||
},
|
||||
},
|
||||
InstanceRequirements: &ec2.InstanceRequirementsRequest{
|
||||
VCpuCount: &ec2.VCpuCountRangeRequest{Min: aws.Int64(2), Max: aws.Int64(8)},
|
||||
MemoryMiB: &ec2.MemoryMiBRequest{Min: aws.Int64(2048), Max: aws.Int64(16384)},
|
||||
},
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
KernelId: aws.String("aki-1"),
|
||||
KeyName: aws.String("key-1"),
|
||||
LicenseSpecifications: []*ec2.LaunchTemplateLicenseConfigurationRequest{{LicenseConfigurationArn: aws.String("arn:lic")}},
|
||||
MaintenanceOptions: &ec2.LaunchTemplateInstanceMaintenanceOptionsRequest{AutoRecovery: aws.String("default")},
|
||||
MetadataOptions: &ec2.LaunchTemplateInstanceMetadataOptionsRequest{
|
||||
HttpEndpoint: aws.String("enabled"),
|
||||
HttpProtocolIpv6: aws.String("disabled"),
|
||||
HttpPutResponseHopLimit: aws.Int64(2),
|
||||
HttpTokens: aws.String("required"),
|
||||
InstanceMetadataTags: aws.String("enabled"),
|
||||
},
|
||||
Monitoring: &ec2.LaunchTemplatesMonitoringRequest{Enabled: aws.Bool(true)},
|
||||
NetworkInterfaces: []*ec2.LaunchTemplateInstanceNetworkInterfaceSpecificationRequest{{
|
||||
AssociateCarrierIpAddress: aws.Bool(false),
|
||||
AssociatePublicIpAddress: aws.Bool(true),
|
||||
ConnectionTrackingSpecification: &ec2.ConnectionTrackingSpecificationRequest{
|
||||
TcpEstablishedTimeout: aws.Int64(60),
|
||||
UdpStreamTimeout: aws.Int64(60),
|
||||
UdpTimeout: aws.Int64(30),
|
||||
},
|
||||
DeleteOnTermination: aws.Bool(true),
|
||||
Description: aws.String("primary"),
|
||||
DeviceIndex: aws.Int64(0),
|
||||
EnaSrdSpecification: &ec2.EnaSrdSpecificationRequest{
|
||||
EnaSrdEnabled: aws.Bool(true),
|
||||
EnaSrdUdpSpecification: &ec2.EnaSrdUdpSpecificationRequest{EnaSrdUdpEnabled: aws.Bool(true)},
|
||||
},
|
||||
Groups: []*string{aws.String("sg-1")},
|
||||
InterfaceType: aws.String("interface"),
|
||||
Ipv4PrefixCount: aws.Int64(1),
|
||||
Ipv4Prefixes: []*ec2.Ipv4PrefixSpecificationRequest{{Ipv4Prefix: aws.String("10.0.0.0/28")}},
|
||||
Ipv6AddressCount: aws.Int64(1),
|
||||
Ipv6Addresses: []*ec2.InstanceIpv6AddressRequest{{Ipv6Address: aws.String("::1")}},
|
||||
Ipv6PrefixCount: aws.Int64(1),
|
||||
Ipv6Prefixes: []*ec2.Ipv6PrefixSpecificationRequest{{Ipv6Prefix: aws.String("2600::/64")}},
|
||||
NetworkCardIndex: aws.Int64(0),
|
||||
NetworkInterfaceId: aws.String("eni-1"),
|
||||
PrimaryIpv6: aws.Bool(false),
|
||||
PrivateIpAddress: aws.String("10.0.0.5"),
|
||||
PrivateIpAddresses: []*ec2.PrivateIpAddressSpecification{{Primary: aws.Bool(true), PrivateIpAddress: aws.String("10.0.0.5")}},
|
||||
SecondaryPrivateIpAddressCount: aws.Int64(1),
|
||||
SubnetId: aws.String("subnet-1"),
|
||||
}},
|
||||
Placement: &ec2.LaunchTemplatePlacementRequest{
|
||||
Affinity: aws.String("default"),
|
||||
AvailabilityZone: aws.String("az-1"),
|
||||
GroupId: aws.String("pg-1"),
|
||||
GroupName: aws.String("group"),
|
||||
HostId: aws.String("host-1"),
|
||||
HostResourceGroupArn: aws.String("arn:hrg"),
|
||||
PartitionNumber: aws.Int64(1),
|
||||
SpreadDomain: aws.String("domain"),
|
||||
Tenancy: aws.String("default"),
|
||||
},
|
||||
PrivateDnsNameOptions: &ec2.LaunchTemplatePrivateDnsNameOptionsRequest{
|
||||
EnableResourceNameDnsAAAARecord: aws.Bool(true),
|
||||
EnableResourceNameDnsARecord: aws.Bool(true),
|
||||
HostnameType: aws.String("ip-name"),
|
||||
},
|
||||
RamDiskId: aws.String("ari-1"),
|
||||
SecurityGroupIds: []*string{aws.String("sg-2")},
|
||||
SecurityGroups: []*string{aws.String("default")},
|
||||
TagSpecifications: []*ec2.LaunchTemplateTagSpecificationRequest{{
|
||||
ResourceType: aws.String("instance"),
|
||||
Tags: []*ec2.Tag{{Key: aws.String("Name"), Value: aws.String("web")}},
|
||||
}},
|
||||
UserData: aws.String("dXNlcg=="),
|
||||
}
|
||||
}
|
||||
|
||||
// assertNoNilPointerFields fails if any top-level pointer/slice/map field is nil.
|
||||
func assertNoNilPointerFields(t *testing.T, v any, ctx string) {
|
||||
t.Helper()
|
||||
rv := reflect.ValueOf(v)
|
||||
if rv.Kind() == reflect.Pointer {
|
||||
rv = rv.Elem()
|
||||
}
|
||||
rt := rv.Type()
|
||||
for i := 0; i < rv.NumField(); i++ {
|
||||
f := rv.Field(i)
|
||||
switch f.Kind() {
|
||||
case reflect.Pointer, reflect.Slice, reflect.Map, reflect.Interface:
|
||||
assert.Falsef(t, f.IsNil(), "%s: field %s is nil", ctx, rt.Field(i).Name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapperRequestToResponse_NoSilentDrop(t *testing.T) {
|
||||
req := fullRequestData()
|
||||
assertNoNilPointerFields(t, req, "fixture") // fixture completeness
|
||||
|
||||
resp, err := requestToResponse(req)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, resp)
|
||||
assertNoNilPointerFields(t, resp, "response")
|
||||
|
||||
// Deep values across each nested family survive the round-trip.
|
||||
assert.Equal(t, int64(20), aws.Int64Value(resp.BlockDeviceMappings[0].Ebs.VolumeSize))
|
||||
assert.Equal(t, "cr-1", aws.StringValue(resp.CapacityReservationSpecification.CapacityReservationTarget.CapacityReservationId))
|
||||
assert.Equal(t, "required", aws.StringValue(resp.MetadataOptions.HttpTokens))
|
||||
assert.Equal(t, "10.0.0.0/28", aws.StringValue(resp.NetworkInterfaces[0].Ipv4Prefixes[0].Ipv4Prefix))
|
||||
assert.True(t, aws.BoolValue(resp.NetworkInterfaces[0].EnaSrdSpecification.EnaSrdUdpSpecification.EnaSrdUdpEnabled))
|
||||
assert.Equal(t, "Name", aws.StringValue(resp.TagSpecifications[0].Tags[0].Key))
|
||||
assert.Equal(t, int64(8), aws.Int64Value(resp.InstanceRequirements.VCpuCount.Max))
|
||||
}
|
||||
|
||||
func TestMapperResponseToRunInstances_NoSilentDrop(t *testing.T) {
|
||||
resp, err := requestToResponse(fullRequestData())
|
||||
require.NoError(t, err)
|
||||
|
||||
ri, err := responseToRunInstances(resp)
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, ri)
|
||||
|
||||
// Every response field whose name also exists on RunInstancesInput must survive,
|
||||
// except InstanceRequirements (dropped) and the two renamed fields.
|
||||
respType := reflect.TypeOf(*resp)
|
||||
riVal := reflect.ValueOf(*ri)
|
||||
riType := riVal.Type()
|
||||
for field := range respType.Fields() {
|
||||
name := field.Name
|
||||
switch name {
|
||||
case "InstanceRequirements":
|
||||
continue // no RunInstances equivalent
|
||||
case "RamDiskId":
|
||||
assert.NotNil(t, ri.RamdiskId, "RamDiskId not remapped to RamdiskId")
|
||||
continue
|
||||
case "ElasticGpuSpecifications":
|
||||
assert.NotNil(t, ri.ElasticGpuSpecification, "ElasticGpuSpecifications not remapped")
|
||||
continue
|
||||
}
|
||||
sf, ok := riType.FieldByName(name)
|
||||
if !ok {
|
||||
t.Errorf("ResponseLaunchTemplateData.%s has no identically-named RunInstancesInput field and is not a known dropped/renamed field; responseToRunInstances would silently drop it", name)
|
||||
continue
|
||||
}
|
||||
f := riVal.FieldByIndex(sf.Index)
|
||||
switch f.Kind() {
|
||||
case reflect.Pointer, reflect.Slice, reflect.Map:
|
||||
assert.Falsef(t, f.IsNil(), "RunInstancesInput field %s dropped", name)
|
||||
}
|
||||
}
|
||||
|
||||
// Renamed and nested values land correctly.
|
||||
assert.Equal(t, "ari-1", aws.StringValue(ri.RamdiskId))
|
||||
assert.Equal(t, "eg1.medium", aws.StringValue(ri.ElasticGpuSpecification[0].Type))
|
||||
assert.Equal(t, int64(20), aws.Int64Value(ri.BlockDeviceMappings[0].Ebs.VolumeSize))
|
||||
assert.Equal(t, "10.0.0.0/28", aws.StringValue(ri.NetworkInterfaces[0].Ipv4Prefixes[0].Ipv4Prefix))
|
||||
assert.Equal(t, "required", aws.StringValue(ri.MetadataOptions.HttpTokens))
|
||||
}
|
||||
|
||||
func TestMapperNilInputs(t *testing.T) {
|
||||
resp, err := requestToResponse(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, resp)
|
||||
|
||||
ri, err := responseToRunInstances(nil)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, ri)
|
||||
}
|
||||
|
||||
func TestMergeResponseData_PresenceReplace(t *testing.T) {
|
||||
base := &ec2.ResponseLaunchTemplateData{
|
||||
ImageId: aws.String("ami-base"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
SecurityGroupIds: []*string{aws.String("sg-base")},
|
||||
}
|
||||
override := &ec2.ResponseLaunchTemplateData{
|
||||
InstanceType: aws.String("t3.large"), // non-nil replaces
|
||||
SecurityGroupIds: []*string{}, // non-nil empty clears
|
||||
KeyName: aws.String("key-new"), // set where base was nil
|
||||
// ImageId nil -> inherits base
|
||||
}
|
||||
got := mergeResponseData(base, override)
|
||||
assert.Equal(t, "ami-base", aws.StringValue(got.ImageId), "nil override inherits base")
|
||||
assert.Equal(t, "t3.large", aws.StringValue(got.InstanceType), "non-nil override replaces")
|
||||
assert.Equal(t, "key-new", aws.StringValue(got.KeyName))
|
||||
require.NotNil(t, got.SecurityGroupIds, "explicit empty slice is non-nil")
|
||||
assert.Len(t, got.SecurityGroupIds, 0, "explicit empty slice clears base")
|
||||
}
|
||||
|
||||
func TestMergeRunInstancesInput_Precedence(t *testing.T) {
|
||||
base := &ec2.RunInstancesInput{
|
||||
ImageId: aws.String("ami-tmpl"),
|
||||
InstanceType: aws.String("t3.micro"),
|
||||
KeyName: aws.String("key-tmpl"),
|
||||
}
|
||||
override := &ec2.RunInstancesInput{
|
||||
InstanceType: aws.String("t3.large"), // direct param wins
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(3),
|
||||
// ImageId, KeyName nil -> inherit template
|
||||
}
|
||||
got := mergeRunInstancesInput(base, override)
|
||||
assert.Equal(t, "ami-tmpl", aws.StringValue(got.ImageId), "template inherited when direct nil")
|
||||
assert.Equal(t, "t3.large", aws.StringValue(got.InstanceType), "direct param overrides template")
|
||||
assert.Equal(t, "key-tmpl", aws.StringValue(got.KeyName))
|
||||
assert.Equal(t, int64(1), aws.Int64Value(got.MinCount))
|
||||
assert.Equal(t, int64(3), aws.Int64Value(got.MaxCount))
|
||||
}
|
||||
|
||||
func TestMergeRunInstancesInput_MinMaxNeverFromTemplate(t *testing.T) {
|
||||
// A template can never carry MinCount/MaxCount, but assert they come from the
|
||||
// direct request even if a base somehow did.
|
||||
base := &ec2.RunInstancesInput{MinCount: aws.Int64(9), MaxCount: aws.Int64(9)}
|
||||
override := &ec2.RunInstancesInput{MinCount: aws.Int64(2), MaxCount: aws.Int64(4)}
|
||||
got := mergeRunInstancesInput(base, override)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(got.MinCount))
|
||||
assert.Equal(t, int64(4), aws.Int64Value(got.MaxCount))
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package handlers_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
)
|
||||
|
||||
// LaunchTemplateService defines the interface for EC2 launch template operations.
|
||||
type LaunchTemplateService interface {
|
||||
CreateLaunchTemplate(ctx context.Context, input *ec2.CreateLaunchTemplateInput, accountID string) (*ec2.CreateLaunchTemplateOutput, error)
|
||||
CreateLaunchTemplateVersion(ctx context.Context, input *ec2.CreateLaunchTemplateVersionInput, accountID string) (*ec2.CreateLaunchTemplateVersionOutput, error)
|
||||
DeleteLaunchTemplate(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, accountID string) (*ec2.DeleteLaunchTemplateOutput, error)
|
||||
DeleteLaunchTemplateVersions(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, accountID string) (*ec2.DeleteLaunchTemplateVersionsOutput, error)
|
||||
ModifyLaunchTemplate(ctx context.Context, input *ec2.ModifyLaunchTemplateInput, accountID string) (*ec2.ModifyLaunchTemplateOutput, error)
|
||||
DescribeLaunchTemplates(ctx context.Context, input *ec2.DescribeLaunchTemplatesInput, accountID string) (*ec2.DescribeLaunchTemplatesOutput, error)
|
||||
DescribeLaunchTemplateVersions(ctx context.Context, input *ec2.DescribeLaunchTemplateVersionsInput, accountID string) (*ec2.DescribeLaunchTemplateVersionsOutput, error)
|
||||
}
|
||||
|
||||
// LaunchTemplateHeader is the only mutable record for a template: the default
|
||||
// version pointer plus tags. It is the source of truth for the template's
|
||||
// existence and is keyed by id (account.lt-<id>).
|
||||
type LaunchTemplateHeader struct {
|
||||
LaunchTemplateId string `json:"launch_template_id"` // "lt-..."
|
||||
LaunchTemplateName string `json:"launch_template_name"`
|
||||
AccountID string `json:"account_id"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
CreateTime time.Time `json:"create_time"`
|
||||
DefaultVersionNumber int64 `json:"default_version_number"`
|
||||
Tags map[string]string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// LaunchTemplateVersionRec is a single immutable version body, keyed by id and
|
||||
// number (account.lt-<id>.v<n>). Once written it is never mutated.
|
||||
type LaunchTemplateVersionRec struct {
|
||||
VersionNumber int64 `json:"version_number"`
|
||||
VersionDescription string `json:"version_description,omitempty"`
|
||||
CreateTime time.Time `json:"create_time"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
Data *ec2.ResponseLaunchTemplateData `json:"data"`
|
||||
}
|
||||
@@ -0,0 +1,928 @@
|
||||
package handlers_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/config"
|
||||
"github.com/mulgadc/spinifex/spinifex/filterutil"
|
||||
"github.com/mulgadc/spinifex/spinifex/migrate"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// Ensure LaunchTemplateServiceImpl implements LaunchTemplateService.
|
||||
var _ LaunchTemplateService = (*LaunchTemplateServiceImpl)(nil)
|
||||
|
||||
const (
|
||||
KVBucketLaunchTemplates = "spinifex-launch-templates"
|
||||
KVBucketLaunchTemplatesVersion = 1
|
||||
|
||||
// launchTemplateTagResourceType is the TagSpecification.ResourceType that tags
|
||||
// the template itself (as opposed to the instances it later launches).
|
||||
launchTemplateTagResourceType = "launch-template"
|
||||
|
||||
// maxVersionRetries bounds the optimistic version-number kv.Create retry loop.
|
||||
maxVersionRetries = 8
|
||||
|
||||
// versionDefault and versionLatest are the AWS version aliases.
|
||||
versionDefault = "$Default"
|
||||
versionLatest = "$Latest"
|
||||
)
|
||||
|
||||
// LaunchTemplateServiceImpl implements launch template operations with NATS
|
||||
// JetStream persistence. Templates use three key kinds, all account-scoped:
|
||||
// header (account.lt-<id>), name index (account.name.<name> -> lt-<id>), and
|
||||
// immutable version bodies (account.lt-<id>.v<n>).
|
||||
type LaunchTemplateServiceImpl struct {
|
||||
config *config.Config
|
||||
natsConn *nats.Conn
|
||||
kv nats.KeyValue
|
||||
}
|
||||
|
||||
// NewLaunchTemplateServiceImplWithNATS creates a launch template service with NATS JetStream.
|
||||
func NewLaunchTemplateServiceImplWithNATS(cfg *config.Config, natsConn *nats.Conn) (*LaunchTemplateServiceImpl, error) {
|
||||
js, err := natsConn.JetStream()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get JetStream context: %w", err)
|
||||
}
|
||||
|
||||
kv, err := utils.GetOrCreateKVBucket(js, KVBucketLaunchTemplates, 10)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create KV bucket %s: %w", KVBucketLaunchTemplates, err)
|
||||
}
|
||||
if err := migrate.DefaultRegistry.RunKV(KVBucketLaunchTemplates, kv, KVBucketLaunchTemplatesVersion); err != nil {
|
||||
return nil, fmt.Errorf("migrate %s: %w", KVBucketLaunchTemplates, err)
|
||||
}
|
||||
|
||||
slog.Info("Launch template service initialized with JetStream KV", "bucket", KVBucketLaunchTemplates)
|
||||
|
||||
return &LaunchTemplateServiceImpl{
|
||||
config: cfg,
|
||||
natsConn: natsConn,
|
||||
kv: kv,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- key helpers ---
|
||||
|
||||
// headerKey returns the header key: account.lt-<id>.
|
||||
func headerKey(accountID, ltID string) string {
|
||||
return utils.AccountKey(accountID, ltID)
|
||||
}
|
||||
|
||||
// nameKey returns the name-index key: account.name.<hex-encoded-name>.
|
||||
// Launch template names allow characters such as parentheses that are not valid
|
||||
// in NATS KV keys, so the name must not be used in its raw form here.
|
||||
func nameKey(accountID, name string) string {
|
||||
return utils.AccountKey(accountID, "name."+hex.EncodeToString([]byte(name)))
|
||||
}
|
||||
|
||||
// versionKey returns a version-body key: account.lt-<id>.v<n>.
|
||||
func versionKey(accountID, ltID string, n int64) string {
|
||||
return utils.AccountKey(accountID, ltID+".v"+strconv.FormatInt(n, 10))
|
||||
}
|
||||
|
||||
// versionPrefix returns the scan prefix for a template's version bodies.
|
||||
func versionPrefix(accountID, ltID string) string {
|
||||
return utils.AccountKey(accountID, ltID+".v")
|
||||
}
|
||||
|
||||
// --- record load helpers ---
|
||||
|
||||
// getHeaderByID reads a header and its KV entry (for CAS) by launch template id.
|
||||
func (s *LaunchTemplateServiceImpl) getHeaderByID(accountID, ltID string) (*LaunchTemplateHeader, nats.KeyValueEntry, error) {
|
||||
entry, err := s.kv.Get(headerKey(accountID, ltID))
|
||||
if err != nil {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound)
|
||||
}
|
||||
var h LaunchTemplateHeader
|
||||
if err := json.Unmarshal(entry.Value(), &h); err != nil {
|
||||
return nil, nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return &h, entry, nil
|
||||
}
|
||||
|
||||
// getHeaderByName resolves a template name to its header via the name index.
|
||||
// A dangling name index (header missing) is treated as not-found.
|
||||
func (s *LaunchTemplateServiceImpl) getHeaderByName(accountID, name string) (*LaunchTemplateHeader, nats.KeyValueEntry, error) {
|
||||
entry, err := s.kv.Get(nameKey(accountID, name))
|
||||
if err != nil {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException)
|
||||
}
|
||||
ltID := string(entry.Value())
|
||||
h, hentry, err := s.getHeaderByID(accountID, ltID)
|
||||
if err != nil {
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException)
|
||||
}
|
||||
return h, hentry, nil
|
||||
}
|
||||
|
||||
// resolveHeader resolves a template by id or name (mutually exclusive) and
|
||||
// returns the header plus its KV entry for CAS operations.
|
||||
func (s *LaunchTemplateServiceImpl) resolveHeader(accountID string, ltID, name *string) (*LaunchTemplateHeader, nats.KeyValueEntry, error) {
|
||||
id := aws.StringValue(ltID)
|
||||
nm := aws.StringValue(name)
|
||||
switch {
|
||||
case id != "" && nm != "":
|
||||
return nil, nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
case id != "":
|
||||
if err := validateTemplateID(id); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return s.getHeaderByID(accountID, id)
|
||||
case nm != "":
|
||||
return s.getHeaderByName(accountID, nm)
|
||||
default:
|
||||
return nil, nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
}
|
||||
|
||||
// getVersion loads an immutable version body. A missing body is the read-side
|
||||
// guard that turns a dangling default or deleted version into VersionNotFound.
|
||||
func (s *LaunchTemplateServiceImpl) getVersion(accountID, ltID string, n int64) (*LaunchTemplateVersionRec, error) {
|
||||
entry, err := s.kv.Get(versionKey(accountID, ltID, n))
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound)
|
||||
}
|
||||
var rec LaunchTemplateVersionRec
|
||||
if err := json.Unmarshal(entry.Value(), &rec); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return &rec, nil
|
||||
}
|
||||
|
||||
// listVersionNumbers returns the existing version numbers for a template,
|
||||
// derived by scanning its version keys (sorted ascending).
|
||||
func (s *LaunchTemplateServiceImpl) listVersionNumbers(accountID, ltID string) ([]int64, error) {
|
||||
keys, err := s.kv.Keys()
|
||||
if err != nil {
|
||||
if errors.Is(err, nats.ErrNoKeysFound) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
prefix := versionPrefix(accountID, ltID)
|
||||
var nums []int64
|
||||
for _, k := range keys {
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(k[len(prefix):], 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
nums = append(nums, n)
|
||||
}
|
||||
for i := 1; i < len(nums); i++ {
|
||||
for j := i; j > 0 && nums[j-1] > nums[j]; j-- {
|
||||
nums[j-1], nums[j] = nums[j], nums[j-1]
|
||||
}
|
||||
}
|
||||
return nums, nil
|
||||
}
|
||||
|
||||
// latestVersionNumber returns the highest existing version number, or 0 if none.
|
||||
func (s *LaunchTemplateServiceImpl) latestVersionNumber(accountID, ltID string) (int64, error) {
|
||||
nums, err := s.listVersionNumbers(accountID, ltID)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
var highest int64
|
||||
for _, n := range nums {
|
||||
if n > highest {
|
||||
highest = n
|
||||
}
|
||||
}
|
||||
return highest, nil
|
||||
}
|
||||
|
||||
// latestVersionsFromKeys derives the highest existing version number per template
|
||||
// from a single bucket key listing (all keys under prefix account.), avoiding a
|
||||
// per-template rescan. Version keys have the shape account.lt-<id>.v<n>.
|
||||
func latestVersionsFromKeys(keys []string, prefix string) map[string]int64 {
|
||||
latest := make(map[string]int64)
|
||||
for _, k := range keys {
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
continue
|
||||
}
|
||||
rest := k[len(prefix):]
|
||||
if !strings.HasPrefix(rest, "lt-") {
|
||||
continue
|
||||
}
|
||||
id, vStr, found := strings.Cut(rest, ".v")
|
||||
if !found {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.ParseInt(vStr, 10, 64)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if n > latest[id] {
|
||||
latest[id] = n
|
||||
}
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
// resolveVersionNumber maps a selector ("", $Default, $Latest, or numeric) to a
|
||||
// concrete version number. It does not verify the body exists — callers load the
|
||||
// body and surface VersionNotFound when it is missing.
|
||||
func (s *LaunchTemplateServiceImpl) resolveVersionNumber(accountID string, h *LaunchTemplateHeader, sel string) (int64, error) {
|
||||
switch sel {
|
||||
case "", versionDefault:
|
||||
return h.DefaultVersionNumber, nil
|
||||
case versionLatest:
|
||||
return s.latestVersionNumber(accountID, h.LaunchTemplateId)
|
||||
default:
|
||||
n, err := strconv.ParseInt(sel, 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New(awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
}
|
||||
|
||||
// --- CreateLaunchTemplate ---
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) CreateLaunchTemplate(ctx context.Context, input *ec2.CreateLaunchTemplateInput, accountID string) (*ec2.CreateLaunchTemplateOutput, error) {
|
||||
if input.LaunchTemplateData == nil {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
name := aws.StringValue(input.LaunchTemplateName)
|
||||
if err := validateTemplateName(name); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if aws.BoolValue(input.DryRun) {
|
||||
return &ec2.CreateLaunchTemplateOutput{}, nil
|
||||
}
|
||||
|
||||
data, err := requestToResponse(input.LaunchTemplateData)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
ltID := utils.GenerateResourceID("lt")
|
||||
if err := s.claimName(accountID, name, ltID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
rec := LaunchTemplateVersionRec{
|
||||
VersionNumber: 1,
|
||||
VersionDescription: aws.StringValue(input.VersionDescription),
|
||||
CreateTime: now,
|
||||
CreatedBy: accountID,
|
||||
Data: data,
|
||||
}
|
||||
if err := s.putVersion(accountID, ltID, &rec); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
header := LaunchTemplateHeader{
|
||||
LaunchTemplateId: ltID,
|
||||
LaunchTemplateName: name,
|
||||
AccountID: accountID,
|
||||
CreatedBy: accountID,
|
||||
CreateTime: now,
|
||||
DefaultVersionNumber: 1,
|
||||
Tags: utils.ExtractTags(input.TagSpecifications, launchTemplateTagResourceType),
|
||||
}
|
||||
if err := s.putHeader(accountID, &header); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "CreateLaunchTemplate completed", "launchTemplateId", ltID, "name", name, "accountID", accountID)
|
||||
|
||||
return &ec2.CreateLaunchTemplateOutput{
|
||||
LaunchTemplate: headerToEC2(&header, 1),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// claimName atomically reserves a template name via kv.Create, with
|
||||
// repair-on-write: if the name already exists but its header is gone (a crash
|
||||
// orphan), reclaim it via a revision-guarded CAS update. Only a name whose header
|
||||
// still exists returns AlreadyExists.
|
||||
func (s *LaunchTemplateServiceImpl) claimName(accountID, name, ltID string) error {
|
||||
key := nameKey(accountID, name)
|
||||
if _, err := s.kv.Create(key, []byte(ltID)); err == nil {
|
||||
return nil
|
||||
}
|
||||
entry, err := s.kv.Get(key)
|
||||
if err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
// Only a genuinely missing header is a crash orphan safe to reclaim. A live
|
||||
// header means the name is taken; any other read error (transient fault, or a
|
||||
// concurrent in-flight create whose header is not yet written) fails closed so
|
||||
// the name is never stolen from a possibly-live template.
|
||||
_, herr := s.kv.Get(headerKey(accountID, string(entry.Value())))
|
||||
switch {
|
||||
case herr == nil:
|
||||
return errors.New(awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException)
|
||||
case !errors.Is(herr, nats.ErrKeyNotFound):
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.kv.Update(key, []byte(ltID), entry.Revision()); err != nil {
|
||||
return errors.New(awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) putHeader(accountID string, h *LaunchTemplateHeader) error {
|
||||
data, err := json.Marshal(h)
|
||||
if err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.kv.Put(headerKey(accountID, h.LaunchTemplateId), data); err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) putVersion(accountID, ltID string, rec *LaunchTemplateVersionRec) error {
|
||||
data, err := json.Marshal(rec)
|
||||
if err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.kv.Put(versionKey(accountID, ltID, rec.VersionNumber), data); err != nil {
|
||||
return errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- CreateLaunchTemplateVersion ---
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) CreateLaunchTemplateVersion(ctx context.Context, input *ec2.CreateLaunchTemplateVersionInput, accountID string) (*ec2.CreateLaunchTemplateVersionOutput, error) {
|
||||
if input.LaunchTemplateData == nil {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
header, _, err := s.resolveHeader(accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if aws.BoolValue(input.DryRun) {
|
||||
return &ec2.CreateLaunchTemplateVersionOutput{}, nil
|
||||
}
|
||||
|
||||
override, err := requestToResponse(input.LaunchTemplateData)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
// SourceVersion clones-and-overrides; without it the new version is the data as given.
|
||||
data := override
|
||||
if src := aws.StringValue(input.SourceVersion); src != "" {
|
||||
n, err := s.resolveVersionNumber(accountID, header, src)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
base, err := s.getVersion(accountID, header.LaunchTemplateId, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
data = mergeResponseData(base.Data, override)
|
||||
}
|
||||
|
||||
rec := LaunchTemplateVersionRec{
|
||||
VersionDescription: aws.StringValue(input.VersionDescription),
|
||||
CreateTime: time.Now().UTC(),
|
||||
CreatedBy: accountID,
|
||||
Data: data,
|
||||
}
|
||||
|
||||
// Assign the number with an atomic kv.Create in a bounded retry loop: on
|
||||
// collision another writer took n, so rescan and try the next number.
|
||||
for attempt := range maxVersionRetries {
|
||||
latest, err := s.latestVersionNumber(accountID, header.LaunchTemplateId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
next := latest + 1
|
||||
rec.VersionNumber = next
|
||||
body, err := json.Marshal(&rec)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.kv.Create(versionKey(accountID, header.LaunchTemplateId, next), body); err != nil {
|
||||
if errors.Is(err, nats.ErrKeyExists) {
|
||||
slog.DebugContext(ctx, "CreateLaunchTemplateVersion: version-number collision, retrying", "attempt", attempt, "next", next)
|
||||
continue
|
||||
}
|
||||
slog.ErrorContext(ctx, "CreateLaunchTemplateVersion: version-key create failed", "next", next, "err", err)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
slog.InfoContext(ctx, "CreateLaunchTemplateVersion completed", "launchTemplateId", header.LaunchTemplateId, "version", next, "accountID", accountID)
|
||||
return &ec2.CreateLaunchTemplateVersionOutput{
|
||||
LaunchTemplateVersion: versionToEC2(header, &rec),
|
||||
}, nil
|
||||
}
|
||||
|
||||
slog.ErrorContext(ctx, "CreateLaunchTemplateVersion: version-number retries exhausted", "launchTemplateId", header.LaunchTemplateId, "accountID", accountID)
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// --- ModifyLaunchTemplate ---
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) ModifyLaunchTemplate(ctx context.Context, input *ec2.ModifyLaunchTemplateInput, accountID string) (*ec2.ModifyLaunchTemplateOutput, error) {
|
||||
header, entry, err := s.resolveHeader(accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if aws.BoolValue(input.DryRun) {
|
||||
latest, err := s.latestVersionNumber(accountID, header.LaunchTemplateId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ec2.ModifyLaunchTemplateOutput{LaunchTemplate: headerToEC2(header, latest)}, nil
|
||||
}
|
||||
|
||||
if sel := aws.StringValue(input.DefaultVersion); sel != "" {
|
||||
n, err := s.resolveVersionNumber(accountID, header, sel)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// Verify the target body exists immediately before the header CAS so a
|
||||
// concurrent delete degrades to VersionNotFound, never a dangling default.
|
||||
if _, err := s.getVersion(accountID, header.LaunchTemplateId, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
header.DefaultVersionNumber = n
|
||||
data, err := json.Marshal(header)
|
||||
if err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if _, err := s.kv.Update(headerKey(accountID, header.LaunchTemplateId), data, entry.Revision()); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
}
|
||||
|
||||
latest, err := s.latestVersionNumber(accountID, header.LaunchTemplateId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
slog.InfoContext(ctx, "ModifyLaunchTemplate completed", "launchTemplateId", header.LaunchTemplateId, "defaultVersion", header.DefaultVersionNumber, "accountID", accountID)
|
||||
return &ec2.ModifyLaunchTemplateOutput{LaunchTemplate: headerToEC2(header, latest)}, nil
|
||||
}
|
||||
|
||||
// --- DeleteLaunchTemplate ---
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplate(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, accountID string) (*ec2.DeleteLaunchTemplateOutput, error) {
|
||||
header, _, err := s.resolveHeader(accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if aws.BoolValue(input.DryRun) {
|
||||
latest, err := s.latestVersionNumber(accountID, header.LaunchTemplateId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ec2.DeleteLaunchTemplateOutput{LaunchTemplate: headerToEC2(header, latest)}, nil
|
||||
}
|
||||
|
||||
latest, err := s.latestVersionNumber(accountID, header.LaunchTemplateId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := headerToEC2(header, latest)
|
||||
|
||||
// Delete the header first: the template immediately vanishes from every
|
||||
// describe. Version bodies and the name index are best-effort cleanup.
|
||||
if err := s.kv.Delete(headerKey(accountID, header.LaunchTemplateId)); err != nil {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
if err := s.kv.Delete(nameKey(accountID, header.LaunchTemplateName)); err != nil {
|
||||
slog.WarnContext(ctx, "DeleteLaunchTemplate: name index cleanup failed", "name", header.LaunchTemplateName, "err", err)
|
||||
}
|
||||
nums, err := s.listVersionNumbers(accountID, header.LaunchTemplateId)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "DeleteLaunchTemplate: version enumeration failed, bodies left in place", "launchTemplateId", header.LaunchTemplateId, "err", err)
|
||||
}
|
||||
for _, n := range nums {
|
||||
if err := s.kv.Delete(versionKey(accountID, header.LaunchTemplateId, n)); err != nil {
|
||||
slog.WarnContext(ctx, "DeleteLaunchTemplate: version cleanup failed", "launchTemplateId", header.LaunchTemplateId, "version", n, "err", err)
|
||||
}
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "DeleteLaunchTemplate completed", "launchTemplateId", header.LaunchTemplateId, "accountID", accountID)
|
||||
return &ec2.DeleteLaunchTemplateOutput{LaunchTemplate: out}, nil
|
||||
}
|
||||
|
||||
// --- DeleteLaunchTemplateVersions ---
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) DeleteLaunchTemplateVersions(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, accountID string) (*ec2.DeleteLaunchTemplateVersionsOutput, error) {
|
||||
header, _, err := s.resolveHeader(accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(input.Versions) == 0 {
|
||||
return nil, errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
|
||||
out := &ec2.DeleteLaunchTemplateVersionsOutput{}
|
||||
if aws.BoolValue(input.DryRun) {
|
||||
return out, nil
|
||||
}
|
||||
|
||||
for _, v := range input.Versions {
|
||||
sel := aws.StringValue(v)
|
||||
n, perr := strconv.ParseInt(sel, 10, 64)
|
||||
if perr != nil {
|
||||
out.UnsuccessfullyDeletedLaunchTemplateVersions = append(out.UnsuccessfullyDeletedLaunchTemplateVersions,
|
||||
deleteErrorItem(header, 0, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, "invalid version number"))
|
||||
continue
|
||||
}
|
||||
if n == header.DefaultVersionNumber {
|
||||
out.UnsuccessfullyDeletedLaunchTemplateVersions = append(out.UnsuccessfullyDeletedLaunchTemplateVersions,
|
||||
deleteErrorItem(header, n, awserrors.ErrorInvalidParameterValue, "cannot delete the default version of a launch template"))
|
||||
continue
|
||||
}
|
||||
if _, err := s.getVersion(accountID, header.LaunchTemplateId, n); err != nil {
|
||||
out.UnsuccessfullyDeletedLaunchTemplateVersions = append(out.UnsuccessfullyDeletedLaunchTemplateVersions,
|
||||
deleteErrorItem(header, n, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, "version does not exist"))
|
||||
continue
|
||||
}
|
||||
if err := s.kv.Delete(versionKey(accountID, header.LaunchTemplateId, n)); err != nil {
|
||||
out.UnsuccessfullyDeletedLaunchTemplateVersions = append(out.UnsuccessfullyDeletedLaunchTemplateVersions,
|
||||
deleteErrorItem(header, n, awserrors.ErrorServerInternal, "failed to delete version"))
|
||||
continue
|
||||
}
|
||||
out.SuccessfullyDeletedLaunchTemplateVersions = append(out.SuccessfullyDeletedLaunchTemplateVersions,
|
||||
&ec2.DeleteLaunchTemplateVersionsResponseSuccessItem{
|
||||
LaunchTemplateId: aws.String(header.LaunchTemplateId),
|
||||
LaunchTemplateName: aws.String(header.LaunchTemplateName),
|
||||
VersionNumber: aws.Int64(n),
|
||||
})
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "DeleteLaunchTemplateVersions completed", "launchTemplateId", header.LaunchTemplateId, "deleted", len(out.SuccessfullyDeletedLaunchTemplateVersions), "failed", len(out.UnsuccessfullyDeletedLaunchTemplateVersions), "accountID", accountID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func deleteErrorItem(h *LaunchTemplateHeader, n int64, code, msg string) *ec2.DeleteLaunchTemplateVersionsResponseErrorItem {
|
||||
item := &ec2.DeleteLaunchTemplateVersionsResponseErrorItem{
|
||||
LaunchTemplateId: aws.String(h.LaunchTemplateId),
|
||||
LaunchTemplateName: aws.String(h.LaunchTemplateName),
|
||||
ResponseError: &ec2.ResponseError{Code: aws.String(code), Message: aws.String(msg)},
|
||||
}
|
||||
if n > 0 {
|
||||
item.VersionNumber = aws.Int64(n)
|
||||
}
|
||||
return item
|
||||
}
|
||||
|
||||
// --- DescribeLaunchTemplates ---
|
||||
|
||||
var describeLaunchTemplatesValidFilters = map[string]bool{
|
||||
"create-time": true,
|
||||
"launch-template-name": true,
|
||||
"launch-template-id": true,
|
||||
"tag-key": true,
|
||||
}
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) DescribeLaunchTemplates(ctx context.Context, input *ec2.DescribeLaunchTemplatesInput, accountID string) (*ec2.DescribeLaunchTemplatesOutput, error) {
|
||||
filters, err := filterutil.ParseFilters(input.Filters, describeLaunchTemplatesValidFilters)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "DescribeLaunchTemplates: invalid filter", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
for _, id := range input.LaunchTemplateIds {
|
||||
if err := validateTemplateID(aws.StringValue(id)); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
nameSet := stringSet(input.LaunchTemplateNames)
|
||||
idSet := stringSet(input.LaunchTemplateIds)
|
||||
|
||||
prefix := accountID + "."
|
||||
keys, err := s.kv.Keys()
|
||||
if err != nil && !errors.Is(err, nats.ErrNoKeysFound) {
|
||||
return nil, errors.New(awserrors.ErrorServerInternal)
|
||||
}
|
||||
|
||||
// Derive every template's latest version number from this single key list so
|
||||
// the loop below does not re-scan the whole bucket once per matched template.
|
||||
latestByID := latestVersionsFromKeys(keys, prefix)
|
||||
|
||||
var templates []*ec2.LaunchTemplate
|
||||
foundNames := make(map[string]bool)
|
||||
foundIDs := make(map[string]bool)
|
||||
for _, k := range keys {
|
||||
if !isHeaderKey(k, prefix) {
|
||||
continue
|
||||
}
|
||||
entry, err := s.kv.Get(k)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "DescribeLaunchTemplates: header read failed", "key", k, "err", err)
|
||||
continue
|
||||
}
|
||||
var h LaunchTemplateHeader
|
||||
if err := json.Unmarshal(entry.Value(), &h); err != nil {
|
||||
slog.WarnContext(ctx, "DescribeLaunchTemplates: bad header record", "key", k, "err", err)
|
||||
continue
|
||||
}
|
||||
if len(nameSet) > 0 && !nameSet[h.LaunchTemplateName] {
|
||||
continue
|
||||
}
|
||||
if len(idSet) > 0 && !idSet[h.LaunchTemplateId] {
|
||||
continue
|
||||
}
|
||||
if !templateMatchesFilters(&h, filters) {
|
||||
continue
|
||||
}
|
||||
foundNames[h.LaunchTemplateName] = true
|
||||
foundIDs[h.LaunchTemplateId] = true
|
||||
templates = append(templates, headerToEC2(&h, latestByID[h.LaunchTemplateId]))
|
||||
}
|
||||
|
||||
for name := range nameSet {
|
||||
if !foundNames[name] {
|
||||
return nil, errors.New(awserrors.ErrorInvalidLaunchTemplateNameNotFoundException)
|
||||
}
|
||||
}
|
||||
for id := range idSet {
|
||||
if !foundIDs[id] {
|
||||
return nil, errors.New(awserrors.ErrorInvalidLaunchTemplateIdNotFound)
|
||||
}
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "DescribeLaunchTemplates completed", "count", len(templates), "accountID", accountID)
|
||||
return &ec2.DescribeLaunchTemplatesOutput{LaunchTemplates: templates}, nil
|
||||
}
|
||||
|
||||
func templateMatchesFilters(h *LaunchTemplateHeader, filters map[string][]string) bool {
|
||||
for name, values := range filters {
|
||||
if strings.HasPrefix(name, "tag:") {
|
||||
continue
|
||||
}
|
||||
switch name {
|
||||
case "launch-template-id":
|
||||
if !filterutil.MatchesAny(values, h.LaunchTemplateId) {
|
||||
return false
|
||||
}
|
||||
case "launch-template-name":
|
||||
if !filterutil.MatchesAny(values, h.LaunchTemplateName) {
|
||||
return false
|
||||
}
|
||||
case "create-time":
|
||||
if !filterutil.MatchesAny(values, h.CreateTime.Format(time.RFC3339)) {
|
||||
return false
|
||||
}
|
||||
case "tag-key":
|
||||
matched := false
|
||||
for k := range h.Tags {
|
||||
if filterutil.MatchesAny(values, k) {
|
||||
matched = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !matched {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return filterutil.MatchesTags(filters, h.Tags)
|
||||
}
|
||||
|
||||
// --- DescribeLaunchTemplateVersions ---
|
||||
|
||||
var describeLaunchTemplateVersionsValidFilters = map[string]bool{
|
||||
"is-default-version": true,
|
||||
"image-id": true,
|
||||
"instance-type": true,
|
||||
"kernel-id": true,
|
||||
"ram-disk-id": true,
|
||||
"ebs-optimized": true,
|
||||
}
|
||||
|
||||
func (s *LaunchTemplateServiceImpl) DescribeLaunchTemplateVersions(ctx context.Context, input *ec2.DescribeLaunchTemplateVersionsInput, accountID string) (*ec2.DescribeLaunchTemplateVersionsOutput, error) {
|
||||
filters, err := filterutil.ParseFilters(input.Filters, describeLaunchTemplateVersionsValidFilters)
|
||||
if err != nil {
|
||||
slog.WarnContext(ctx, "DescribeLaunchTemplateVersions: invalid filter", "err", err)
|
||||
return nil, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
|
||||
header, _, err := s.resolveHeader(accountID, input.LaunchTemplateId, input.LaunchTemplateName)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
numbers, err := s.selectVersionNumbers(accountID, header, input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var versions []*ec2.LaunchTemplateVersion
|
||||
for _, n := range numbers {
|
||||
rec, err := s.getVersion(accountID, header.LaunchTemplateId, n)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !versionMatchesFilters(rec, n == header.DefaultVersionNumber, filters) {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, versionRecToEC2(header, rec, n == header.DefaultVersionNumber))
|
||||
}
|
||||
|
||||
slog.InfoContext(ctx, "DescribeLaunchTemplateVersions completed", "launchTemplateId", header.LaunchTemplateId, "count", len(versions), "accountID", accountID)
|
||||
return &ec2.DescribeLaunchTemplateVersionsOutput{LaunchTemplateVersions: versions}, nil
|
||||
}
|
||||
|
||||
// selectVersionNumbers resolves the requested Versions/MinVersion/MaxVersion into
|
||||
// a concrete, ordered list of version numbers. Explicit Versions each resolve and
|
||||
// must exist; otherwise all versions within the optional [Min,Max] range.
|
||||
func (s *LaunchTemplateServiceImpl) selectVersionNumbers(accountID string, header *LaunchTemplateHeader, input *ec2.DescribeLaunchTemplateVersionsInput) ([]int64, error) {
|
||||
if len(input.Versions) > 0 {
|
||||
seen := make(map[int64]bool)
|
||||
var out []int64
|
||||
for _, v := range input.Versions {
|
||||
n, err := s.resolveVersionNumber(accountID, header, aws.StringValue(v))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if _, err := s.getVersion(accountID, header.LaunchTemplateId, n); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !seen[n] {
|
||||
seen[n] = true
|
||||
out = append(out, n)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
all, err := s.listVersionNumbers(accountID, header.LaunchTemplateId)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
minV, err := parseBoundVersion(aws.StringValue(input.MinVersion))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
maxV, err := parseBoundVersion(aws.StringValue(input.MaxVersion))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
var out []int64
|
||||
for _, n := range all {
|
||||
if minV > 0 && n < minV {
|
||||
continue
|
||||
}
|
||||
if maxV > 0 && n > maxV {
|
||||
continue
|
||||
}
|
||||
out = append(out, n)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseBoundVersion(s string) (int64, error) {
|
||||
if s == "" {
|
||||
return 0, nil
|
||||
}
|
||||
n, err := strconv.ParseInt(s, 10, 64)
|
||||
if err != nil {
|
||||
return 0, errors.New(awserrors.ErrorInvalidParameterValue)
|
||||
}
|
||||
return n, nil
|
||||
}
|
||||
|
||||
func versionMatchesFilters(rec *LaunchTemplateVersionRec, isDefault bool, filters map[string][]string) bool {
|
||||
d := rec.Data
|
||||
for name, values := range filters {
|
||||
switch name {
|
||||
case "is-default-version":
|
||||
if !filterutil.MatchesAny(values, strconv.FormatBool(isDefault)) {
|
||||
return false
|
||||
}
|
||||
case "image-id":
|
||||
if d == nil || !filterutil.MatchesAny(values, aws.StringValue(d.ImageId)) {
|
||||
return false
|
||||
}
|
||||
case "instance-type":
|
||||
if d == nil || !filterutil.MatchesAny(values, aws.StringValue(d.InstanceType)) {
|
||||
return false
|
||||
}
|
||||
case "kernel-id":
|
||||
if d == nil || !filterutil.MatchesAny(values, aws.StringValue(d.KernelId)) {
|
||||
return false
|
||||
}
|
||||
case "ram-disk-id":
|
||||
if d == nil || !filterutil.MatchesAny(values, aws.StringValue(d.RamDiskId)) {
|
||||
return false
|
||||
}
|
||||
case "ebs-optimized":
|
||||
if d == nil || !filterutil.MatchesAny(values, strconv.FormatBool(aws.BoolValue(d.EbsOptimized))) {
|
||||
return false
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// --- converters ---
|
||||
|
||||
// headerToEC2 builds the AWS LaunchTemplate view; latest is the highest existing
|
||||
// version number (0 when the template has no versions).
|
||||
func headerToEC2(h *LaunchTemplateHeader, latest int64) *ec2.LaunchTemplate {
|
||||
lt := &ec2.LaunchTemplate{
|
||||
LaunchTemplateId: aws.String(h.LaunchTemplateId),
|
||||
LaunchTemplateName: aws.String(h.LaunchTemplateName),
|
||||
CreatedBy: aws.String(h.CreatedBy),
|
||||
CreateTime: aws.Time(h.CreateTime),
|
||||
DefaultVersionNumber: aws.Int64(h.DefaultVersionNumber),
|
||||
Tags: utils.MapToEC2Tags(h.Tags),
|
||||
}
|
||||
if latest > 0 {
|
||||
lt.LatestVersionNumber = aws.Int64(latest)
|
||||
}
|
||||
return lt
|
||||
}
|
||||
|
||||
// versionToEC2 builds the AWS view for a freshly-created version (its number is
|
||||
// on the record).
|
||||
func versionToEC2(h *LaunchTemplateHeader, rec *LaunchTemplateVersionRec) *ec2.LaunchTemplateVersion {
|
||||
return versionRecToEC2(h, rec, rec.VersionNumber == h.DefaultVersionNumber)
|
||||
}
|
||||
|
||||
func versionRecToEC2(h *LaunchTemplateHeader, rec *LaunchTemplateVersionRec, isDefault bool) *ec2.LaunchTemplateVersion {
|
||||
return &ec2.LaunchTemplateVersion{
|
||||
LaunchTemplateId: aws.String(h.LaunchTemplateId),
|
||||
LaunchTemplateName: aws.String(h.LaunchTemplateName),
|
||||
VersionNumber: aws.Int64(rec.VersionNumber),
|
||||
VersionDescription: aws.String(rec.VersionDescription),
|
||||
CreateTime: aws.Time(rec.CreateTime),
|
||||
CreatedBy: aws.String(rec.CreatedBy),
|
||||
DefaultVersion: aws.Bool(isDefault),
|
||||
LaunchTemplateData: rec.Data,
|
||||
}
|
||||
}
|
||||
|
||||
// --- misc helpers ---
|
||||
|
||||
// isHeaderKey reports whether k is a template header key (account.lt-<id>) as
|
||||
// opposed to a name index, version body (account.lt-<id>.v<n>), or _version key.
|
||||
func isHeaderKey(k, prefix string) bool {
|
||||
if !strings.HasPrefix(k, prefix) {
|
||||
return false
|
||||
}
|
||||
rest := k[len(prefix):]
|
||||
return strings.HasPrefix(rest, "lt-") && !strings.Contains(rest, ".")
|
||||
}
|
||||
|
||||
func stringSet(in []*string) map[string]bool {
|
||||
if len(in) == 0 {
|
||||
return nil
|
||||
}
|
||||
set := make(map[string]bool, len(in))
|
||||
for _, s := range in {
|
||||
if s != nil {
|
||||
set[*s] = true
|
||||
}
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// validateTemplateID enforces the lt- id prefix so a syntactically invalid id
|
||||
// returns Malformed rather than NotFound, matching image/volume id handling.
|
||||
func validateTemplateID(id string) error {
|
||||
if !strings.HasPrefix(id, "lt-") {
|
||||
return errors.New(awserrors.ErrorInvalidLaunchTemplateIdMalformed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateTemplateName enforces the AWS 3-128 char and allowed-character rules.
|
||||
func validateTemplateName(name string) error {
|
||||
if name == "" {
|
||||
return errors.New(awserrors.ErrorMissingParameter)
|
||||
}
|
||||
if len(name) < 3 || len(name) > 128 {
|
||||
return errors.New(awserrors.ErrorInvalidLaunchTemplateNameMalformedException)
|
||||
}
|
||||
for _, c := range name {
|
||||
switch {
|
||||
case c >= 'a' && c <= 'z', c >= 'A' && c <= 'Z', c >= '0' && c <= '9':
|
||||
case strings.ContainsRune("-_./()", c):
|
||||
default:
|
||||
return errors.New(awserrors.ErrorInvalidLaunchTemplateNameMalformedException)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,508 @@
|
||||
package handlers_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/awserrors"
|
||||
"github.com/mulgadc/spinifex/spinifex/testutil"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
const (
|
||||
testAccountID = "123456789012"
|
||||
otherAccountID = "210987654321"
|
||||
)
|
||||
|
||||
func setupTestService(t *testing.T) *LaunchTemplateServiceImpl {
|
||||
t.Helper()
|
||||
_, nc, _ := testutil.StartTestJetStream(t)
|
||||
svc, err := NewLaunchTemplateServiceImplWithNATS(nil, nc)
|
||||
require.NoError(t, err)
|
||||
return svc
|
||||
}
|
||||
|
||||
func createTemplate(t *testing.T, svc *LaunchTemplateServiceImpl, name, instanceType string) *ec2.LaunchTemplate {
|
||||
t.Helper()
|
||||
out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String(name),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
ImageId: aws.String("ami-123"),
|
||||
InstanceType: aws.String(instanceType),
|
||||
},
|
||||
TagSpecifications: []*ec2.TagSpecification{{
|
||||
ResourceType: aws.String(launchTemplateTagResourceType),
|
||||
Tags: []*ec2.Tag{{Key: aws.String("env"), Value: aws.String("test")}},
|
||||
}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return out.LaunchTemplate
|
||||
}
|
||||
|
||||
// --- CreateLaunchTemplate ---
|
||||
|
||||
func TestCreateLaunchTemplate_Basic(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
|
||||
assert.Equal(t, "lt-", (*lt.LaunchTemplateId)[:3])
|
||||
assert.Equal(t, "web", aws.StringValue(lt.LaunchTemplateName))
|
||||
assert.Equal(t, int64(1), aws.Int64Value(lt.DefaultVersionNumber))
|
||||
assert.Equal(t, int64(1), aws.Int64Value(lt.LatestVersionNumber))
|
||||
require.Len(t, lt.Tags, 1)
|
||||
assert.Equal(t, "env", aws.StringValue(lt.Tags[0].Key))
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplate_MissingData(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
_, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorMissingParameter, err.Error())
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplate_NameMalformed(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
_, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("ab"), // < 3 chars
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{ImageId: aws.String("ami-1")},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateNameMalformedException, err.Error())
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplate_DuplicateName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTemplate(t, svc, "dup", "t3.micro")
|
||||
_, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("dup"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{ImageId: aws.String("ami-1")},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateNameAlreadyExistsException, err.Error())
|
||||
}
|
||||
|
||||
func TestLaunchTemplateNameWithKVUnsafeCharacters(t *testing.T) {
|
||||
for _, name := range []string{"foo(bar)", "foo."} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTemplate(t, svc, name, "t3.micro")
|
||||
|
||||
// Name-based operations must use the same encoded index key.
|
||||
_, err := svc.CreateLaunchTemplateVersion(context.Background(), &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateName: aws.String(name),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{InstanceType: aws.String("t3.large")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String(name),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplate_DryRunNoPersist(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
DryRun: aws.Bool(true),
|
||||
LaunchTemplateName: aws.String("dry"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{ImageId: aws.String("ami-1")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, out.LaunchTemplate)
|
||||
|
||||
// Nothing persisted: the name is still claimable.
|
||||
createTemplate(t, svc, "dry", "t3.micro")
|
||||
}
|
||||
|
||||
// TestCreateLaunchTemplate_OrphanNameReclaim verifies repair-on-write: a name
|
||||
// whose header is gone (crash orphan) is reclaimed by the next create.
|
||||
func TestCreateLaunchTemplate_OrphanNameReclaim(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "orphan", "t3.micro")
|
||||
|
||||
// Simulate a crash after the name claim but with the header lost.
|
||||
require.NoError(t, svc.kv.Delete(headerKey(testAccountID, aws.StringValue(lt.LaunchTemplateId))))
|
||||
|
||||
// The name index still points at the now-orphaned id; create must reclaim it.
|
||||
out, err := svc.CreateLaunchTemplate(context.Background(), &ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String("orphan"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{ImageId: aws.String("ami-2")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.NotEqual(t, aws.StringValue(lt.LaunchTemplateId), aws.StringValue(out.LaunchTemplate.LaunchTemplateId))
|
||||
}
|
||||
|
||||
// --- DescribeLaunchTemplates ---
|
||||
|
||||
func TestDescribeLaunchTemplates_ByName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTemplate(t, svc, "web", "t3.micro")
|
||||
createTemplate(t, svc, "dbx", "t3.large")
|
||||
|
||||
out, err := svc.DescribeLaunchTemplates(context.Background(), &ec2.DescribeLaunchTemplatesInput{
|
||||
LaunchTemplateNames: []*string{aws.String("web")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LaunchTemplates, 1)
|
||||
assert.Equal(t, "web", aws.StringValue(out.LaunchTemplates[0].LaunchTemplateName))
|
||||
}
|
||||
|
||||
func TestDescribeLaunchTemplates_All(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTemplate(t, svc, "web", "t3.micro")
|
||||
createTemplate(t, svc, "dbx", "t3.large")
|
||||
|
||||
out, err := svc.DescribeLaunchTemplates(context.Background(), &ec2.DescribeLaunchTemplatesInput{}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.LaunchTemplates, 2)
|
||||
}
|
||||
|
||||
func TestDescribeLaunchTemplates_UnknownName(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTemplate(t, svc, "web", "t3.micro")
|
||||
_, err := svc.DescribeLaunchTemplates(context.Background(), &ec2.DescribeLaunchTemplatesInput{
|
||||
LaunchTemplateNames: []*string{aws.String("missing")},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateNameNotFoundException, err.Error())
|
||||
}
|
||||
|
||||
func TestDescribeLaunchTemplates_TagFilter(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTemplate(t, svc, "web", "t3.micro")
|
||||
|
||||
out, err := svc.DescribeLaunchTemplates(context.Background(), &ec2.DescribeLaunchTemplatesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("tag:env"), Values: []*string{aws.String("test")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.LaunchTemplates, 1)
|
||||
|
||||
out, err = svc.DescribeLaunchTemplates(context.Background(), &ec2.DescribeLaunchTemplatesInput{
|
||||
Filters: []*ec2.Filter{{Name: aws.String("tag:env"), Values: []*string{aws.String("prod")}}},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.LaunchTemplates)
|
||||
}
|
||||
|
||||
func TestDescribeLaunchTemplates_AccountIsolation(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
createTemplate(t, svc, "web", "t3.micro")
|
||||
|
||||
out, err := svc.DescribeLaunchTemplates(context.Background(), &ec2.DescribeLaunchTemplatesInput{}, otherAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.LaunchTemplates)
|
||||
}
|
||||
|
||||
// --- CreateLaunchTemplateVersion ---
|
||||
|
||||
func addVersion(t *testing.T, svc *LaunchTemplateServiceImpl, ltID, instanceType, sourceVersion string) *ec2.LaunchTemplateVersion {
|
||||
t.Helper()
|
||||
in := &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String(ltID),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
InstanceType: aws.String(instanceType),
|
||||
},
|
||||
}
|
||||
if sourceVersion != "" {
|
||||
in.SourceVersion = aws.String(sourceVersion)
|
||||
}
|
||||
out, err := svc.CreateLaunchTemplateVersion(context.Background(), in, testAccountID)
|
||||
require.NoError(t, err)
|
||||
return out.LaunchTemplateVersion
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplateVersion_Increments(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
|
||||
v2 := addVersion(t, svc, id, "t3.large", "")
|
||||
assert.Equal(t, int64(2), aws.Int64Value(v2.VersionNumber))
|
||||
assert.False(t, aws.BoolValue(v2.DefaultVersion), "new versions do not auto-become default")
|
||||
|
||||
v3 := addVersion(t, svc, id, "t3.xlarge", "")
|
||||
assert.Equal(t, int64(3), aws.Int64Value(v3.VersionNumber))
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplateVersion_SourceVersionMerge(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro") // v1: ImageId=ami-123, InstanceType=t3.micro
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
|
||||
// v2 sources v1 and overrides only InstanceType; ImageId inherits.
|
||||
v2 := addVersion(t, svc, id, "t3.large", "1")
|
||||
assert.Equal(t, "t3.large", aws.StringValue(v2.LaunchTemplateData.InstanceType))
|
||||
assert.Equal(t, "ami-123", aws.StringValue(v2.LaunchTemplateData.ImageId), "unset field inherits source version")
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplateVersion_NoSourceNoInherit(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
|
||||
// Without SourceVersion the new version carries only the given data.
|
||||
v2 := addVersion(t, svc, id, "t3.large", "")
|
||||
assert.Equal(t, "t3.large", aws.StringValue(v2.LaunchTemplateData.InstanceType))
|
||||
assert.Nil(t, v2.LaunchTemplateData.ImageId, "no SourceVersion means no inheritance")
|
||||
}
|
||||
|
||||
func TestCreateLaunchTemplateVersion_ConcurrentNumbering(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
|
||||
const n = 8
|
||||
var wg sync.WaitGroup
|
||||
var mu sync.Mutex
|
||||
nums := make(map[int64]bool)
|
||||
errs := make([]error, 0)
|
||||
for range n {
|
||||
wg.Go(func() {
|
||||
out, err := svc.CreateLaunchTemplateVersion(context.Background(), &ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{InstanceType: aws.String("t3.large")},
|
||||
}, testAccountID)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if err != nil {
|
||||
errs = append(errs, err)
|
||||
return
|
||||
}
|
||||
nums[aws.Int64Value(out.LaunchTemplateVersion.VersionNumber)] = true
|
||||
})
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
require.Empty(t, errs, "no version create should fail under contention")
|
||||
assert.Len(t, nums, n, "every concurrent version got a unique number")
|
||||
}
|
||||
|
||||
// --- DescribeLaunchTemplateVersions ---
|
||||
|
||||
func TestDescribeLaunchTemplateVersions_DefaultAndLatest(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
addVersion(t, svc, id, "t3.large", "") // v2
|
||||
addVersion(t, svc, id, "t3.xlarge", "") // v3
|
||||
|
||||
out, err := svc.DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
Versions: []*string{aws.String(versionDefault)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LaunchTemplateVersions, 1)
|
||||
assert.Equal(t, int64(1), aws.Int64Value(out.LaunchTemplateVersions[0].VersionNumber))
|
||||
assert.True(t, aws.BoolValue(out.LaunchTemplateVersions[0].DefaultVersion))
|
||||
|
||||
out, err = svc.DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
Versions: []*string{aws.String(versionLatest)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LaunchTemplateVersions, 1)
|
||||
assert.Equal(t, int64(3), aws.Int64Value(out.LaunchTemplateVersions[0].VersionNumber))
|
||||
}
|
||||
|
||||
func TestDescribeLaunchTemplateVersions_AllAndRange(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
addVersion(t, svc, id, "a", "")
|
||||
addVersion(t, svc, id, "b", "")
|
||||
addVersion(t, svc, id, "c", "") // versions 1..4
|
||||
|
||||
out, err := svc.DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Len(t, out.LaunchTemplateVersions, 4)
|
||||
|
||||
out, err = svc.DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
MinVersion: aws.String("2"),
|
||||
MaxVersion: aws.String("3"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LaunchTemplateVersions, 2)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(out.LaunchTemplateVersions[0].VersionNumber))
|
||||
assert.Equal(t, int64(3), aws.Int64Value(out.LaunchTemplateVersions[1].VersionNumber))
|
||||
}
|
||||
|
||||
func TestDescribeLaunchTemplateVersions_MissingVersion(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
_, err := svc.DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: lt.LaunchTemplateId,
|
||||
Versions: []*string{aws.String("99")},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, err.Error())
|
||||
}
|
||||
|
||||
func TestDescribeLaunchTemplateVersions_LatestAfterTailDelete(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
addVersion(t, svc, id, "a", "") // v2
|
||||
addVersion(t, svc, id, "b", "") // v3
|
||||
|
||||
_, err := svc.DeleteLaunchTemplateVersions(context.Background(), &ec2.DeleteLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
Versions: []*string{aws.String("3")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
out, err := svc.DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
Versions: []*string{aws.String(versionLatest)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.LaunchTemplateVersions, 1)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(out.LaunchTemplateVersions[0].VersionNumber))
|
||||
}
|
||||
|
||||
// --- ModifyLaunchTemplate ---
|
||||
|
||||
func TestModifyLaunchTemplate_SetDefault(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
addVersion(t, svc, id, "t3.large", "") // v2
|
||||
|
||||
out, err := svc.ModifyLaunchTemplate(context.Background(), &ec2.ModifyLaunchTemplateInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
DefaultVersion: aws.String("2"),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(out.LaunchTemplate.DefaultVersionNumber))
|
||||
|
||||
// $Default now resolves to v2.
|
||||
desc, err := svc.DescribeLaunchTemplateVersions(context.Background(), &ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
Versions: []*string{aws.String(versionDefault)},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(desc.LaunchTemplateVersions[0].VersionNumber))
|
||||
}
|
||||
|
||||
func TestModifyLaunchTemplate_DefaultToMissingVersion(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
_, err := svc.ModifyLaunchTemplate(context.Background(), &ec2.ModifyLaunchTemplateInput{
|
||||
LaunchTemplateId: lt.LaunchTemplateId,
|
||||
DefaultVersion: aws.String("42"),
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdVersionNotFound, err.Error())
|
||||
}
|
||||
|
||||
// --- DeleteLaunchTemplateVersions ---
|
||||
|
||||
func TestDeleteLaunchTemplateVersions_RejectDefault(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro") // default = v1
|
||||
out, err := svc.DeleteLaunchTemplateVersions(context.Background(), &ec2.DeleteLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: lt.LaunchTemplateId,
|
||||
Versions: []*string{aws.String("1")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, out.SuccessfullyDeletedLaunchTemplateVersions)
|
||||
require.Len(t, out.UnsuccessfullyDeletedLaunchTemplateVersions, 1)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue,
|
||||
aws.StringValue(out.UnsuccessfullyDeletedLaunchTemplateVersions[0].ResponseError.Code))
|
||||
}
|
||||
|
||||
func TestDeleteLaunchTemplateVersions_SuccessAndMissing(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
addVersion(t, svc, id, "a", "") // v2
|
||||
|
||||
out, err := svc.DeleteLaunchTemplateVersions(context.Background(), &ec2.DeleteLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
Versions: []*string{aws.String("2"), aws.String("9")},
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
require.Len(t, out.SuccessfullyDeletedLaunchTemplateVersions, 1)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(out.SuccessfullyDeletedLaunchTemplateVersions[0].VersionNumber))
|
||||
require.Len(t, out.UnsuccessfullyDeletedLaunchTemplateVersions, 1)
|
||||
assert.Equal(t, int64(9), aws.Int64Value(out.UnsuccessfullyDeletedLaunchTemplateVersions[0].VersionNumber))
|
||||
}
|
||||
|
||||
// --- DeleteLaunchTemplate ---
|
||||
|
||||
func TestDeleteLaunchTemplate_RemovesEverything(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
id := aws.StringValue(lt.LaunchTemplateId)
|
||||
addVersion(t, svc, id, "a", "")
|
||||
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{
|
||||
LaunchTemplateId: aws.String(id),
|
||||
}, testAccountID)
|
||||
require.NoError(t, err)
|
||||
|
||||
// Header gone: describe returns not found.
|
||||
_, err = svc.DescribeLaunchTemplates(context.Background(), &ec2.DescribeLaunchTemplatesInput{
|
||||
LaunchTemplateIds: []*string{aws.String(id)},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, err.Error())
|
||||
|
||||
// Version bodies and name index gone.
|
||||
nums, err := svc.listVersionNumbers(testAccountID, id)
|
||||
require.NoError(t, err)
|
||||
assert.Empty(t, nums)
|
||||
_, err = svc.kv.Get(nameKey(testAccountID, "web"))
|
||||
require.Error(t, err)
|
||||
|
||||
// Name is reusable after delete.
|
||||
createTemplate(t, svc, "web", "t3.large")
|
||||
}
|
||||
|
||||
func TestDeleteLaunchTemplate_Unknown(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{
|
||||
LaunchTemplateId: aws.String("lt-doesnotexist000"),
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdNotFound, err.Error())
|
||||
}
|
||||
|
||||
func TestResolveHeader_IdNameConflict(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
lt := createTemplate(t, svc, "web", "t3.micro")
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{
|
||||
LaunchTemplateId: lt.LaunchTemplateId,
|
||||
LaunchTemplateName: aws.String("web"),
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidParameterValue, err.Error())
|
||||
}
|
||||
|
||||
func TestResolveHeader_MalformedId(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
_, err := svc.DeleteLaunchTemplate(context.Background(), &ec2.DeleteLaunchTemplateInput{
|
||||
LaunchTemplateId: aws.String("not-an-lt-id"),
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdMalformed, err.Error())
|
||||
}
|
||||
|
||||
func TestDescribeLaunchTemplates_MalformedId(t *testing.T) {
|
||||
svc := setupTestService(t)
|
||||
_, err := svc.DescribeLaunchTemplates(context.Background(), &ec2.DescribeLaunchTemplatesInput{
|
||||
LaunchTemplateIds: []*string{aws.String("bad-id")},
|
||||
}, testAccountID)
|
||||
require.Error(t, err)
|
||||
assert.Equal(t, awserrors.ErrorInvalidLaunchTemplateIdMalformed, err.Error())
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package handlers_ec2_launchtemplate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/spinifex/utils"
|
||||
"github.com/nats-io/nats.go"
|
||||
)
|
||||
|
||||
// NATSLaunchTemplateService is the gateway-side client that forwards launch
|
||||
// template operations to the daemon-hosted service impl over NATS.
|
||||
type NATSLaunchTemplateService struct {
|
||||
natsConn *nats.Conn
|
||||
}
|
||||
|
||||
var _ LaunchTemplateService = (*NATSLaunchTemplateService)(nil)
|
||||
|
||||
// NewNATSLaunchTemplateService creates a new NATS-based launch template service.
|
||||
func NewNATSLaunchTemplateService(conn *nats.Conn) LaunchTemplateService {
|
||||
return &NATSLaunchTemplateService{natsConn: conn}
|
||||
}
|
||||
|
||||
func (s *NATSLaunchTemplateService) CreateLaunchTemplate(ctx context.Context, input *ec2.CreateLaunchTemplateInput, accountID string) (*ec2.CreateLaunchTemplateOutput, error) {
|
||||
return utils.NATSRequest[ec2.CreateLaunchTemplateOutput](ctx, s.natsConn, "ec2.CreateLaunchTemplate", input, 30*time.Second, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSLaunchTemplateService) CreateLaunchTemplateVersion(ctx context.Context, input *ec2.CreateLaunchTemplateVersionInput, accountID string) (*ec2.CreateLaunchTemplateVersionOutput, error) {
|
||||
return utils.NATSRequest[ec2.CreateLaunchTemplateVersionOutput](ctx, s.natsConn, "ec2.CreateLaunchTemplateVersion", input, 30*time.Second, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSLaunchTemplateService) DeleteLaunchTemplate(ctx context.Context, input *ec2.DeleteLaunchTemplateInput, accountID string) (*ec2.DeleteLaunchTemplateOutput, error) {
|
||||
return utils.NATSRequest[ec2.DeleteLaunchTemplateOutput](ctx, s.natsConn, "ec2.DeleteLaunchTemplate", input, 30*time.Second, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSLaunchTemplateService) DeleteLaunchTemplateVersions(ctx context.Context, input *ec2.DeleteLaunchTemplateVersionsInput, accountID string) (*ec2.DeleteLaunchTemplateVersionsOutput, error) {
|
||||
return utils.NATSRequest[ec2.DeleteLaunchTemplateVersionsOutput](ctx, s.natsConn, "ec2.DeleteLaunchTemplateVersions", input, 30*time.Second, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSLaunchTemplateService) ModifyLaunchTemplate(ctx context.Context, input *ec2.ModifyLaunchTemplateInput, accountID string) (*ec2.ModifyLaunchTemplateOutput, error) {
|
||||
return utils.NATSRequest[ec2.ModifyLaunchTemplateOutput](ctx, s.natsConn, "ec2.ModifyLaunchTemplate", input, 30*time.Second, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSLaunchTemplateService) DescribeLaunchTemplates(ctx context.Context, input *ec2.DescribeLaunchTemplatesInput, accountID string) (*ec2.DescribeLaunchTemplatesOutput, error) {
|
||||
return utils.NATSRequest[ec2.DescribeLaunchTemplatesOutput](ctx, s.natsConn, "ec2.DescribeLaunchTemplates", input, 30*time.Second, accountID)
|
||||
}
|
||||
|
||||
func (s *NATSLaunchTemplateService) DescribeLaunchTemplateVersions(ctx context.Context, input *ec2.DescribeLaunchTemplateVersionsInput, accountID string) (*ec2.DescribeLaunchTemplateVersionsOutput, error) {
|
||||
return utils.NATSRequest[ec2.DescribeLaunchTemplateVersionsOutput](ctx, s.natsConn, "ec2.DescribeLaunchTemplateVersions", input, 30*time.Second, accountID)
|
||||
}
|
||||
@@ -16,26 +16,28 @@ import (
|
||||
// setup is shared across the suite. Read/describe/delete/modify calls are not
|
||||
// listed — only calls that bring a new resource into existence.
|
||||
var createAPIs = map[string]bool{
|
||||
"RunInstances": true,
|
||||
"CreateImage": true,
|
||||
"RegisterImage": true,
|
||||
"CreateVpc": true,
|
||||
"CreateSubnet": true,
|
||||
"CreateSecurityGroup": true,
|
||||
"CreateKeyPair": true,
|
||||
"ImportKeyPair": true,
|
||||
"CreateVolume": true,
|
||||
"CreateSnapshot": true,
|
||||
"CreateNatGateway": true,
|
||||
"CreateInternetGateway": true,
|
||||
"CreateRouteTable": true,
|
||||
"CreateLoadBalancer": true,
|
||||
"CreateTargetGroup": true,
|
||||
"CreateListener": true,
|
||||
"AllocateAddress": true,
|
||||
"CreateCluster": true,
|
||||
"CreateNodegroup": true,
|
||||
"CreateNetworkInterface": true,
|
||||
"RunInstances": true,
|
||||
"CreateImage": true,
|
||||
"RegisterImage": true,
|
||||
"CreateVpc": true,
|
||||
"CreateSubnet": true,
|
||||
"CreateSecurityGroup": true,
|
||||
"CreateKeyPair": true,
|
||||
"ImportKeyPair": true,
|
||||
"CreateLaunchTemplate": true,
|
||||
"CreateLaunchTemplateVersion": true,
|
||||
"CreateVolume": true,
|
||||
"CreateSnapshot": true,
|
||||
"CreateNatGateway": true,
|
||||
"CreateInternetGateway": true,
|
||||
"CreateRouteTable": true,
|
||||
"CreateLoadBalancer": true,
|
||||
"CreateTargetGroup": true,
|
||||
"CreateListener": true,
|
||||
"AllocateAddress": true,
|
||||
"CreateCluster": true,
|
||||
"CreateNodegroup": true,
|
||||
"CreateNetworkInterface": true,
|
||||
}
|
||||
|
||||
// awsClientFields are the AWSClient struct fields a create call must be made
|
||||
|
||||
@@ -0,0 +1,206 @@
|
||||
//go:build e2e
|
||||
|
||||
package single
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/aws/aws-sdk-go/aws"
|
||||
"github.com/aws/aws-sdk-go/service/ec2"
|
||||
"github.com/mulgadc/spinifex/tests/e2e/harness"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// runLaunchTemplates drives the launch-template control plane through the
|
||||
// deployed AWS gateway, then launches an instance from the selected default
|
||||
// version. It intentionally uses parentheses in the name: they are valid AWS
|
||||
// launch-template characters but require the service's KV name-index encoding.
|
||||
func runLaunchTemplates(t *testing.T, fix *Fixture) {
|
||||
harness.Phase(t, "Single — EC2 Launch Templates")
|
||||
|
||||
amiID := needAMI(t, fix)
|
||||
instanceType, _ := needInstanceTypeArch(t, fix)
|
||||
keyName, _ := needKeyPair(t, fix)
|
||||
templateName := fmt.Sprintf("e2e-lt-(%s)", fix.Harness.Scratch())
|
||||
|
||||
// e2e:allow-create — the launch template lifecycle is the subject under test.
|
||||
createOut, err := fix.AWS.EC2.CreateLaunchTemplate(&ec2.CreateLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String(templateName),
|
||||
VersionDescription: aws.String("initial version"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
ImageId: aws.String(amiID),
|
||||
InstanceType: aws.String(instanceType),
|
||||
KeyName: aws.String(keyName),
|
||||
},
|
||||
TagSpecifications: []*ec2.TagSpecification{{
|
||||
ResourceType: aws.String("launch-template"),
|
||||
Tags: []*ec2.Tag{
|
||||
{Key: aws.String("e2e-suite"), Value: aws.String(fix.Harness.Scratch())},
|
||||
},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err, "create-launch-template")
|
||||
require.NotNil(t, createOut.LaunchTemplate, "create-launch-template returned no template")
|
||||
templateID := aws.StringValue(createOut.LaunchTemplate.LaunchTemplateId)
|
||||
require.NotEmpty(t, templateID, "create-launch-template returned an empty id")
|
||||
assert.Equal(t, int64(1), aws.Int64Value(createOut.LaunchTemplate.DefaultVersionNumber))
|
||||
|
||||
templateDeleted := false
|
||||
t.Cleanup(func() {
|
||||
if templateDeleted {
|
||||
return
|
||||
}
|
||||
if _, err := fix.AWS.EC2.DeleteLaunchTemplate(&ec2.DeleteLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String(templateName),
|
||||
}); err != nil {
|
||||
t.Logf("cleanup delete-launch-template %q: %v", templateName, err)
|
||||
}
|
||||
})
|
||||
|
||||
harness.Step(t, "describe-launch-templates by launch-template tag")
|
||||
descTemplates, err := fix.AWS.EC2.DescribeLaunchTemplates(&ec2.DescribeLaunchTemplatesInput{
|
||||
Filters: []*ec2.Filter{{
|
||||
Name: aws.String("tag:e2e-suite"),
|
||||
Values: []*string{aws.String(fix.Harness.Scratch())},
|
||||
}},
|
||||
})
|
||||
require.NoError(t, err, "describe-launch-templates by tag")
|
||||
require.Len(t, descTemplates.LaunchTemplates, 1)
|
||||
assert.Equal(t, templateID, aws.StringValue(descTemplates.LaunchTemplates[0].LaunchTemplateId))
|
||||
|
||||
// SourceVersion inherits the AMI and instance type from v1 while the
|
||||
// non-nil UserData field replaces only that whole field.
|
||||
const userData = "ZWNobyBsYXVuY2gtdGVtcGxhdGUK"
|
||||
// e2e:allow-create — version creation is part of the launch-template lifecycle under test.
|
||||
v2Out, err := fix.AWS.EC2.CreateLaunchTemplateVersion(&ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateName: aws.String(templateName),
|
||||
SourceVersion: aws.String("1"),
|
||||
VersionDescription: aws.String("source merge version"),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
UserData: aws.String(userData),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create-launch-template-version from source")
|
||||
require.NotNil(t, v2Out.LaunchTemplateVersion, "create-launch-template-version returned no version")
|
||||
v2 := v2Out.LaunchTemplateVersion
|
||||
assert.Equal(t, int64(2), aws.Int64Value(v2.VersionNumber))
|
||||
require.NotNil(t, v2.LaunchTemplateData)
|
||||
assert.Equal(t, amiID, aws.StringValue(v2.LaunchTemplateData.ImageId))
|
||||
assert.Equal(t, instanceType, aws.StringValue(v2.LaunchTemplateData.InstanceType))
|
||||
assert.Equal(t, userData, aws.StringValue(v2.LaunchTemplateData.UserData))
|
||||
|
||||
harness.Step(t, "describe-launch-template-versions $Latest by name")
|
||||
latestOut, err := fix.AWS.EC2.DescribeLaunchTemplateVersions(&ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateName: aws.String(templateName),
|
||||
Versions: []*string{aws.String("$Latest")},
|
||||
})
|
||||
require.NoError(t, err, "describe-launch-template-versions $Latest")
|
||||
require.Len(t, latestOut.LaunchTemplateVersions, 1)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(latestOut.LaunchTemplateVersions[0].VersionNumber))
|
||||
|
||||
harness.Step(t, "modify-launch-template default version -> 2")
|
||||
modifyOut, err := fix.AWS.EC2.ModifyLaunchTemplate(&ec2.ModifyLaunchTemplateInput{
|
||||
LaunchTemplateId: aws.String(templateID),
|
||||
DefaultVersion: aws.String("2"),
|
||||
})
|
||||
require.NoError(t, err, "modify-launch-template")
|
||||
require.NotNil(t, modifyOut.LaunchTemplate)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(modifyOut.LaunchTemplate.DefaultVersionNumber))
|
||||
|
||||
// Create and remove a tail version so DeleteLaunchTemplateVersions and
|
||||
// $Latest-after-delete are covered without disturbing the default v2. This
|
||||
// version also drives a nested timestamp through the EC2 query parser.
|
||||
validUntil := time.Date(2030, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||
// e2e:allow-create — version creation is part of the launch-template lifecycle under test.
|
||||
v3Out, err := fix.AWS.EC2.CreateLaunchTemplateVersion(&ec2.CreateLaunchTemplateVersionInput{
|
||||
LaunchTemplateId: aws.String(templateID),
|
||||
LaunchTemplateData: &ec2.RequestLaunchTemplateData{
|
||||
InstanceType: aws.String(instanceType),
|
||||
InstanceMarketOptions: &ec2.LaunchTemplateInstanceMarketOptionsRequest{
|
||||
MarketType: aws.String("spot"),
|
||||
SpotOptions: &ec2.LaunchTemplateSpotMarketOptionsRequest{
|
||||
ValidUntil: aws.Time(validUntil),
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "create-launch-template-version tail")
|
||||
require.NotNil(t, v3Out.LaunchTemplateVersion)
|
||||
assert.Equal(t, int64(3), aws.Int64Value(v3Out.LaunchTemplateVersion.VersionNumber))
|
||||
|
||||
latestOut, err = fix.AWS.EC2.DescribeLaunchTemplateVersions(&ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(templateID),
|
||||
Versions: []*string{aws.String("$Latest")},
|
||||
})
|
||||
require.NoError(t, err, "describe-launch-template-versions $Latest tail")
|
||||
require.Len(t, latestOut.LaunchTemplateVersions, 1)
|
||||
require.NotNil(t, latestOut.LaunchTemplateVersions[0].LaunchTemplateData)
|
||||
require.NotNil(t, latestOut.LaunchTemplateVersions[0].LaunchTemplateData.InstanceMarketOptions)
|
||||
require.NotNil(t, latestOut.LaunchTemplateVersions[0].LaunchTemplateData.InstanceMarketOptions.SpotOptions)
|
||||
require.NotNil(t, latestOut.LaunchTemplateVersions[0].LaunchTemplateData.InstanceMarketOptions.SpotOptions.ValidUntil)
|
||||
assert.Equal(t, validUntil, *latestOut.LaunchTemplateVersions[0].LaunchTemplateData.InstanceMarketOptions.SpotOptions.ValidUntil)
|
||||
|
||||
deleteVersionsOut, err := fix.AWS.EC2.DeleteLaunchTemplateVersions(&ec2.DeleteLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(templateID),
|
||||
Versions: []*string{aws.String("3")},
|
||||
})
|
||||
require.NoError(t, err, "delete-launch-template-versions")
|
||||
require.Len(t, deleteVersionsOut.SuccessfullyDeletedLaunchTemplateVersions, 1)
|
||||
assert.Equal(t, int64(3), aws.Int64Value(deleteVersionsOut.SuccessfullyDeletedLaunchTemplateVersions[0].VersionNumber))
|
||||
assert.Empty(t, deleteVersionsOut.UnsuccessfullyDeletedLaunchTemplateVersions)
|
||||
|
||||
latestOut, err = fix.AWS.EC2.DescribeLaunchTemplateVersions(&ec2.DescribeLaunchTemplateVersionsInput{
|
||||
LaunchTemplateId: aws.String(templateID),
|
||||
Versions: []*string{aws.String("$Latest")},
|
||||
})
|
||||
require.NoError(t, err, "describe-launch-template-versions $Latest after delete")
|
||||
require.Len(t, latestOut.LaunchTemplateVersions, 1)
|
||||
assert.Equal(t, int64(2), aws.Int64Value(latestOut.LaunchTemplateVersions[0].VersionNumber))
|
||||
|
||||
vpc := harness.EnsureDefaultVPC(t, fix.Harness)
|
||||
require.NotEmpty(t, vpc.SubnetID)
|
||||
require.NotEmpty(t, vpc.SGID)
|
||||
|
||||
harness.Step(t, "run-instances from default launch-template version")
|
||||
// e2e:allow-create — the instance verifies launch-template expansion in the real launch path.
|
||||
runOut, err := fix.AWS.EC2.RunInstances(&ec2.RunInstancesInput{
|
||||
LaunchTemplate: &ec2.LaunchTemplateSpecification{LaunchTemplateId: aws.String(templateID)},
|
||||
MinCount: aws.Int64(1),
|
||||
MaxCount: aws.Int64(1),
|
||||
SubnetId: aws.String(vpc.SubnetID),
|
||||
SecurityGroupIds: []*string{
|
||||
aws.String(vpc.SGID),
|
||||
},
|
||||
})
|
||||
require.NoError(t, err, "run-instances with launch template")
|
||||
require.NotEmpty(t, runOut.Instances, "run-instances returned no instances")
|
||||
instanceID := aws.StringValue(runOut.Instances[0].InstanceId)
|
||||
require.NotEmpty(t, instanceID, "run-instances returned an empty instance id")
|
||||
t.Cleanup(func() {
|
||||
_, _ = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
||||
InstanceIds: []*string{aws.String(instanceID)},
|
||||
})
|
||||
})
|
||||
|
||||
instance := harness.WaitForInstanceState(t, fix.AWS, instanceID, "running")
|
||||
assert.Equal(t, amiID, aws.StringValue(instance.ImageId), "templated AMI")
|
||||
assert.Equal(t, instanceType, aws.StringValue(instance.InstanceType), "templated instance type")
|
||||
|
||||
_, err = fix.AWS.EC2.TerminateInstances(&ec2.TerminateInstancesInput{
|
||||
InstanceIds: []*string{aws.String(instanceID)},
|
||||
})
|
||||
require.NoError(t, err, "terminate launch-template instance")
|
||||
harness.WaitForInstanceState(t, fix.AWS, instanceID, "terminated")
|
||||
|
||||
harness.Step(t, "delete-launch-template by name")
|
||||
deleteOut, err := fix.AWS.EC2.DeleteLaunchTemplate(&ec2.DeleteLaunchTemplateInput{
|
||||
LaunchTemplateName: aws.String(templateName),
|
||||
})
|
||||
require.NoError(t, err, "delete-launch-template")
|
||||
require.NotNil(t, deleteOut.LaunchTemplate)
|
||||
assert.Equal(t, templateID, aws.StringValue(deleteOut.LaunchTemplate.LaunchTemplateId))
|
||||
templateDeleted = true
|
||||
}
|
||||
@@ -180,6 +180,12 @@ func TestRunInstancesMultiCount(t *testing.T) {
|
||||
runRunInstancesMultiCount(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// TestLaunchTemplates launches a throwaway VM to verify the referenced
|
||||
// template's effective image and instance type, so it runs sequentially.
|
||||
func TestLaunchTemplates(t *testing.T) {
|
||||
runLaunchTemplates(t, requireSingleNodeFixture(t))
|
||||
}
|
||||
|
||||
// TestSpotInstanceLifecycle drives the Spot Instance Request mock: request ->
|
||||
// fulfilled -> running -> cancel/terminate terminal transitions. Sequential: it
|
||||
// consumes node capacity for its own short-lived VMs like the multi-count test.
|
||||
|
||||
Reference in New Issue
Block a user