From 8fe8a9436cb458d10f098f4a84b56513b3b47fe4 Mon Sep 17 00:00:00 2001 From: Julian Sommer <115516918+juliansommer@users.noreply.github.com> Date: Fri, 10 Jul 2026 14:41:04 +1000 Subject: [PATCH] feat(ec2): EC2 Launch Templates (#498) --- docs/COMMANDS.md | 14 +- spinifex/awsec2query/parser.go | 11 + spinifex/awsec2query/parser_test.go | 25 + spinifex/daemon/daemon.go | 18 + .../daemon/daemon_handlers_launchtemplate.go | 31 + spinifex/gateway/ec2.go | 22 + spinifex/gateway/ec2/instance/RunInstances.go | 17 + .../launchtemplate/CreateLaunchTemplate.go | 44 + .../CreateLaunchTemplateVersion.go | 41 + .../launchtemplate/DeleteLaunchTemplate.go | 36 + .../DeleteLaunchTemplateVersions.go | 44 + .../DescribeLaunchTemplateVersions.go | 38 + .../launchtemplate/DescribeLaunchTemplates.go | 24 + .../launchtemplate/ModifyLaunchTemplate.go | 37 + .../ec2/launchtemplate/launchtemplate_test.go | 165 ++++ .../gateway/ec2/launchtemplate/validate.go | 25 + .../handlers/ec2/launchtemplate/expand.go | 66 ++ .../ec2/launchtemplate/expand_test.go | 119 +++ .../handlers/ec2/launchtemplate/mappers.go | 117 +++ .../ec2/launchtemplate/mappers_test.go | 284 ++++++ .../handlers/ec2/launchtemplate/service.go | 42 + .../ec2/launchtemplate/service_impl.go | 928 ++++++++++++++++++ .../ec2/launchtemplate/service_impl_test.go | 508 ++++++++++ .../ec2/launchtemplate/service_nats.go | 51 + tests/e2e/manifest-lint/fixtures.go | 42 +- tests/e2e/single/launchtemplate_test.go | 206 ++++ tests/e2e/single/tests_test.go | 6 + 27 files changed, 2935 insertions(+), 26 deletions(-) create mode 100644 spinifex/daemon/daemon_handlers_launchtemplate.go create mode 100644 spinifex/gateway/ec2/launchtemplate/CreateLaunchTemplate.go create mode 100644 spinifex/gateway/ec2/launchtemplate/CreateLaunchTemplateVersion.go create mode 100644 spinifex/gateway/ec2/launchtemplate/DeleteLaunchTemplate.go create mode 100644 spinifex/gateway/ec2/launchtemplate/DeleteLaunchTemplateVersions.go create mode 100644 spinifex/gateway/ec2/launchtemplate/DescribeLaunchTemplateVersions.go create mode 100644 spinifex/gateway/ec2/launchtemplate/DescribeLaunchTemplates.go create mode 100644 spinifex/gateway/ec2/launchtemplate/ModifyLaunchTemplate.go create mode 100644 spinifex/gateway/ec2/launchtemplate/launchtemplate_test.go create mode 100644 spinifex/gateway/ec2/launchtemplate/validate.go create mode 100644 spinifex/handlers/ec2/launchtemplate/expand.go create mode 100644 spinifex/handlers/ec2/launchtemplate/expand_test.go create mode 100644 spinifex/handlers/ec2/launchtemplate/mappers.go create mode 100644 spinifex/handlers/ec2/launchtemplate/mappers_test.go create mode 100644 spinifex/handlers/ec2/launchtemplate/service.go create mode 100644 spinifex/handlers/ec2/launchtemplate/service_impl.go create mode 100644 spinifex/handlers/ec2/launchtemplate/service_impl_test.go create mode 100644 spinifex/handlers/ec2/launchtemplate/service_nats.go create mode 100644 tests/e2e/single/launchtemplate_test.go diff --git a/docs/COMMANDS.md b/docs/COMMANDS.md index 063e935c4..cf870de24 100644 --- a/docs/COMMANDS.md +++ b/docs/COMMANDS.md @@ -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 diff --git a/spinifex/awsec2query/parser.go b/spinifex/awsec2query/parser.go index 3d25925c7..6898a9491 100644 --- a/spinifex/awsec2query/parser.go +++ b/spinifex/awsec2query/parser.go @@ -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()) } diff --git a/spinifex/awsec2query/parser_test.go b/spinifex/awsec2query/parser_test.go index 45f70ed51..9557cc065 100644 --- a/spinifex/awsec2query/parser_test.go +++ b/spinifex/awsec2query/parser_test.go @@ -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) +} diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index b60697146..d794530ba 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -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) }) diff --git a/spinifex/daemon/daemon_handlers_launchtemplate.go b/spinifex/daemon/daemon_handlers_launchtemplate.go new file mode 100644 index 000000000..b564f4b3f --- /dev/null +++ b/spinifex/daemon/daemon_handlers_launchtemplate.go @@ -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) +} diff --git a/spinifex/gateway/ec2.go b/spinifex/gateway/ec2.go index 5e1088fd4..0595a13b0 100644 --- a/spinifex/gateway/ec2.go +++ b/spinifex/gateway/ec2.go @@ -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) diff --git a/spinifex/gateway/ec2/instance/RunInstances.go b/spinifex/gateway/ec2/instance/RunInstances.go index 2de3ee494..8b5f2e8c6 100644 --- a/spinifex/gateway/ec2/instance/RunInstances.go +++ b/spinifex/gateway/ec2/instance/RunInstances.go @@ -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) diff --git a/spinifex/gateway/ec2/launchtemplate/CreateLaunchTemplate.go b/spinifex/gateway/ec2/launchtemplate/CreateLaunchTemplate.go new file mode 100644 index 000000000..13c303d90 --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/CreateLaunchTemplate.go @@ -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 +} diff --git a/spinifex/gateway/ec2/launchtemplate/CreateLaunchTemplateVersion.go b/spinifex/gateway/ec2/launchtemplate/CreateLaunchTemplateVersion.go new file mode 100644 index 000000000..10bf1db40 --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/CreateLaunchTemplateVersion.go @@ -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 +} diff --git a/spinifex/gateway/ec2/launchtemplate/DeleteLaunchTemplate.go b/spinifex/gateway/ec2/launchtemplate/DeleteLaunchTemplate.go new file mode 100644 index 000000000..16a69db21 --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/DeleteLaunchTemplate.go @@ -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 +} diff --git a/spinifex/gateway/ec2/launchtemplate/DeleteLaunchTemplateVersions.go b/spinifex/gateway/ec2/launchtemplate/DeleteLaunchTemplateVersions.go new file mode 100644 index 000000000..15ce8005e --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/DeleteLaunchTemplateVersions.go @@ -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 +} diff --git a/spinifex/gateway/ec2/launchtemplate/DescribeLaunchTemplateVersions.go b/spinifex/gateway/ec2/launchtemplate/DescribeLaunchTemplateVersions.go new file mode 100644 index 000000000..2581d450e --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/DescribeLaunchTemplateVersions.go @@ -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 +} diff --git a/spinifex/gateway/ec2/launchtemplate/DescribeLaunchTemplates.go b/spinifex/gateway/ec2/launchtemplate/DescribeLaunchTemplates.go new file mode 100644 index 000000000..5d68ae618 --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/DescribeLaunchTemplates.go @@ -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 +} diff --git a/spinifex/gateway/ec2/launchtemplate/ModifyLaunchTemplate.go b/spinifex/gateway/ec2/launchtemplate/ModifyLaunchTemplate.go new file mode 100644 index 000000000..c17531195 --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/ModifyLaunchTemplate.go @@ -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 +} diff --git a/spinifex/gateway/ec2/launchtemplate/launchtemplate_test.go b/spinifex/gateway/ec2/launchtemplate/launchtemplate_test.go new file mode 100644 index 000000000..627022e8c --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/launchtemplate_test.go @@ -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()) + }) + } +} diff --git a/spinifex/gateway/ec2/launchtemplate/validate.go b/spinifex/gateway/ec2/launchtemplate/validate.go new file mode 100644 index 000000000..974c2a274 --- /dev/null +++ b/spinifex/gateway/ec2/launchtemplate/validate.go @@ -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 + } +} diff --git a/spinifex/handlers/ec2/launchtemplate/expand.go b/spinifex/handlers/ec2/launchtemplate/expand.go new file mode 100644 index 000000000..88503d4ee --- /dev/null +++ b/spinifex/handlers/ec2/launchtemplate/expand.go @@ -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 +} diff --git a/spinifex/handlers/ec2/launchtemplate/expand_test.go b/spinifex/handlers/ec2/launchtemplate/expand_test.go new file mode 100644 index 000000000..d33f49f9f --- /dev/null +++ b/spinifex/handlers/ec2/launchtemplate/expand_test.go @@ -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()) +} diff --git a/spinifex/handlers/ec2/launchtemplate/mappers.go b/spinifex/handlers/ec2/launchtemplate/mappers.go new file mode 100644 index 000000000..7c67eae30 --- /dev/null +++ b/spinifex/handlers/ec2/launchtemplate/mappers.go @@ -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) + } + } + } +} diff --git a/spinifex/handlers/ec2/launchtemplate/mappers_test.go b/spinifex/handlers/ec2/launchtemplate/mappers_test.go new file mode 100644 index 000000000..f4aeea679 --- /dev/null +++ b/spinifex/handlers/ec2/launchtemplate/mappers_test.go @@ -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)) +} diff --git a/spinifex/handlers/ec2/launchtemplate/service.go b/spinifex/handlers/ec2/launchtemplate/service.go new file mode 100644 index 000000000..c89bb6f42 --- /dev/null +++ b/spinifex/handlers/ec2/launchtemplate/service.go @@ -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-). +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-.v). 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"` +} diff --git a/spinifex/handlers/ec2/launchtemplate/service_impl.go b/spinifex/handlers/ec2/launchtemplate/service_impl.go new file mode 100644 index 000000000..543fc9eb8 --- /dev/null +++ b/spinifex/handlers/ec2/launchtemplate/service_impl.go @@ -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-), name index (account.name. -> lt-), and +// immutable version bodies (account.lt-.v). +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-. +func headerKey(accountID, ltID string) string { + return utils.AccountKey(accountID, ltID) +} + +// nameKey returns the name-index key: account.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-.v. +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-.v. +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-) as +// opposed to a name index, version body (account.lt-.v), 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 +} diff --git a/spinifex/handlers/ec2/launchtemplate/service_impl_test.go b/spinifex/handlers/ec2/launchtemplate/service_impl_test.go new file mode 100644 index 000000000..e96afbfb4 --- /dev/null +++ b/spinifex/handlers/ec2/launchtemplate/service_impl_test.go @@ -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()) +} diff --git a/spinifex/handlers/ec2/launchtemplate/service_nats.go b/spinifex/handlers/ec2/launchtemplate/service_nats.go new file mode 100644 index 000000000..12637fb8e --- /dev/null +++ b/spinifex/handlers/ec2/launchtemplate/service_nats.go @@ -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) +} diff --git a/tests/e2e/manifest-lint/fixtures.go b/tests/e2e/manifest-lint/fixtures.go index 53a6cca6a..1ab1eec7e 100644 --- a/tests/e2e/manifest-lint/fixtures.go +++ b/tests/e2e/manifest-lint/fixtures.go @@ -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 diff --git a/tests/e2e/single/launchtemplate_test.go b/tests/e2e/single/launchtemplate_test.go new file mode 100644 index 000000000..2baec2066 --- /dev/null +++ b/tests/e2e/single/launchtemplate_test.go @@ -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 +} diff --git a/tests/e2e/single/tests_test.go b/tests/e2e/single/tests_test.go index 328c2de71..599958371 100644 --- a/tests/e2e/single/tests_test.go +++ b/tests/e2e/single/tests_test.go @@ -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.