feat(ec2): EC2 Launch Templates (#498)

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