diff --git a/cmd/spinifex/cmd/admin.go b/cmd/spinifex/cmd/admin.go index f19248647..3251198a7 100644 --- a/cmd/spinifex/cmd/admin.go +++ b/cmd/spinifex/cmd/admin.go @@ -279,6 +279,7 @@ func init() { imagesImportCmd.Flags().String("version", "", "Specified distro version (e.g 12)") imagesImportCmd.Flags().String("arch", "", "Specified distro arch (e.g aarch64, arm64, x86_64)") imagesImportCmd.Flags().String("platform", "Linux/UNIX", "Specified platform (e.g Linux/UNIX, Windows)") + imagesImportCmd.Flags().StringSlice("tag", nil, "Tag to apply to the imported AMI as key=value (repeatable; e.g. --tag spinifex:managed-by=elbv2)") imagesImportCmd.Flags().Bool("force", false, "Force command execution (overwrites existing files)") } @@ -313,35 +314,54 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { os.Exit(1) } - // Determine, if name specified, or file + // --name pulls metadata (including Tags) from the catalog; --file supplies + // the local image source. When both are set, the catalog provides metadata + // and the file is used directly (no download). When only --file is set, + // metadata comes from flags and no catalog tags are applied. imageName, _ := cmd.Flags().GetString("name") + localFile, _ := cmd.Flags().GetString("file") + + if imageName == "" && localFile == "" { + fmt.Fprintf(os.Stderr, "Either --name or --file is required to import an image\n") + os.Exit(1) + } if imageName != "" { var exists bool - // Confirm the image exists image, exists = utils.AvailableImages[imageName] - if !exists { fmt.Fprintf(os.Stderr, "Image name not found in available images") os.Exit(1) } - } else { - imageFile, _ = cmd.Flags().GetString("file") + } - if imageFile == "" { - fmt.Fprintf(os.Stderr, "File required to import image") - os.Exit(1) - } - - if _, err := os.Stat(imageFile); err != nil { + if localFile != "" { + if _, err := os.Stat(localFile); err != nil { fmt.Fprintf(os.Stderr, "File could not be found: %s", err) os.Exit(1) } + imageFile = localFile + if imageName == "" { + image.Distro, _ = cmd.Flags().GetString("distro") + image.Version, _ = cmd.Flags().GetString("version") + image.Arch, _ = cmd.Flags().GetString("arch") + image.Platform, _ = cmd.Flags().GetString("platform") + } + } - image.Distro, _ = cmd.Flags().GetString("distro") - image.Version, _ = cmd.Flags().GetString("version") - image.Arch, _ = cmd.Flags().GetString("arch") - image.Platform, _ = cmd.Flags().GetString("platform") + // --tag k=v (repeatable) merges user-supplied tags into the AMI. Overrides + // catalog tags on key collision so operators can re-tag a known image. + tagFlags, _ := cmd.Flags().GetStringSlice("tag") + for _, kv := range tagFlags { + k, v, ok := strings.Cut(kv, "=") + if !ok || k == "" { + fmt.Fprintf(os.Stderr, "Invalid --tag %q: expected key=value\n", kv) + os.Exit(1) + } + if image.Tags == nil { + image.Tags = map[string]string{} + } + image.Tags[k] = v } if image.Distro == "" { @@ -377,7 +397,7 @@ func runimagesImportCmd(cmd *cobra.Command, args []string) { fmt.Printf("✅ Created config directory: %s\n", imagePath) // Next, if the file is selected to download, fetch it, extract disk image, and save to path - if imageName != "" { + if imageName != "" && localFile == "" { // Download the file to the image path filename := path.Base(image.URL) imageFile = fmt.Sprintf("%s/%s", imagePath, filename) diff --git a/scripts/build-system-image.sh b/scripts/build-system-image.sh index 916a8fdea..33b10d9da 100755 --- a/scripts/build-system-image.sh +++ b/scripts/build-system-image.sh @@ -147,11 +147,11 @@ if [[ -f "$OUTPUT_RAW" ]] && [[ $(( $(date +%s) - $(stat -c %Y "$OUTPUT_RAW") )) if [[ "$DO_IMPORT" == true ]]; then echo "Importing as AMI..." - (cd "$PROJECT_DIR" && ./bin/spx admin images import \ - --file "$OUTPUT_RAW" \ - --distro alpine \ - --version "${ALPINE_VERSION}-${IMAGE_NAME}" \ - --arch x86_64) + IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro alpine --version "${ALPINE_VERSION}" --arch x86_64) + if [[ -n "${SYSTEM_TAG:-}" ]]; then + IMPORT_ARGS+=(--tag "$SYSTEM_TAG") + fi + (cd "$PROJECT_DIR" && ./bin/spx admin images import "${IMPORT_ARGS[@]}") fi exit 0 fi @@ -318,14 +318,19 @@ echo "" if [[ "$DO_IMPORT" == true ]]; then echo "Importing as AMI..." - (cd "$PROJECT_DIR" && sudo -u spinifex-storage ./bin/spx admin images import \ - --file "$OUTPUT_RAW" \ - --distro alpine \ - --version "${ALPINE_VERSION}-${IMAGE_NAME}" \ - --arch x86_64) + IMPORT_ARGS=(--file "$OUTPUT_RAW" --distro alpine --version "${ALPINE_VERSION}" --arch x86_64) + if [[ -n "${SYSTEM_TAG:-}" ]]; then + IMPORT_ARGS+=(--tag "$SYSTEM_TAG") + fi + (cd "$PROJECT_DIR" && sudo -u spinifex-storage ./bin/spx admin images import "${IMPORT_ARGS[@]}") else echo "To import as AMI, run:" echo " cd $PROJECT_DIR && ./bin/spx admin images import \\" echo " --file $OUTPUT_RAW \\" - echo " --distro alpine --version ${ALPINE_VERSION}-${IMAGE_NAME} --arch x86_64" + if [[ -n "${SYSTEM_TAG:-}" ]]; then + echo " --distro alpine --version ${ALPINE_VERSION} --arch x86_64 \\" + echo " --tag ${SYSTEM_TAG}" + else + echo " --distro alpine --version ${ALPINE_VERSION} --arch x86_64" + fi fi diff --git a/scripts/images/lb.conf b/scripts/images/lb.conf index 52051452d..1b6e63da0 100644 --- a/scripts/images/lb.conf +++ b/scripts/images/lb.conf @@ -6,6 +6,10 @@ IMAGE_DESCRIPTION="LB VM with HAProxy and lb-agent" ALPINE_VERSION="3.21.6" IMAGE_SIZE="256M" +# Tag applied to the imported AMI so the daemon can discover this as the +# system image for ELBv2 load balancers. Must match tags.ManagedByELBv2. +SYSTEM_TAG="spinifex:managed-by=elbv2" + # Packages to install via apk APK_PACKAGES="haproxy curl ca-certificates" diff --git a/spinifex/daemon/daemon.go b/spinifex/daemon/daemon.go index b20c66e07..0985c4c97 100644 --- a/spinifex/daemon/daemon.go +++ b/spinifex/daemon/daemon.go @@ -50,6 +50,7 @@ import ( "github.com/mulgadc/spinifex/spinifex/instancetypes" "github.com/mulgadc/spinifex/spinifex/objectstore" "github.com/mulgadc/spinifex/spinifex/qmp" + "github.com/mulgadc/spinifex/spinifex/tags" "github.com/mulgadc/spinifex/spinifex/types" "github.com/mulgadc/spinifex/spinifex/utils" "github.com/mulgadc/spinifex/spinifex/vm" @@ -745,25 +746,22 @@ func (d *Daemon) Start() error { // at daemon startup (imported later), so we resolve it at request time. if d.imageService != nil { imgSvc := d.imageService - d.elbv2Service.SetSystemAMIFunc(func() string { - imagesOut, imgErr := imgSvc.DescribeImages(&ec2.DescribeImagesInput{}, utils.GlobalAccountID) - if imgErr != nil || len(imagesOut.Images) == 0 { - return "" + d.elbv2Service.SetSystemAMIFunc(func() (string, error) { + imagesOut, imgErr := imgSvc.DescribeImages(&ec2.DescribeImagesInput{ + Filters: []*ec2.Filter{{ + Name: aws.String("tag:" + tags.ManagedByKey), + Values: []*string{aws.String(tags.ManagedByELBv2)}, + }}, + }, utils.GlobalAccountID) + if imgErr != nil { + return "", fmt.Errorf("describe LB system images: %w", imgErr) } - systemAMI := aws.StringValue(imagesOut.Images[0].ImageId) - // Prefer the new "-lb-" image name, fall back to legacy "-alb-" name. - for _, img := range imagesOut.Images { - name := aws.StringValue(img.Name) - if strings.Contains(name, "-lb-") { - systemAMI = aws.StringValue(img.ImageId) - break - } - if strings.Contains(name, "-alb-") { - systemAMI = aws.StringValue(img.ImageId) - } + if len(imagesOut.Images) == 0 { + return "", errors.New("LB system image not imported; run: spx admin images import --name lb-alpine-3.21.6-x86_64") } - slog.Info("System AMI resolved for LB VMs", "amiId", systemAMI) - return systemAMI + amiID := aws.StringValue(imagesOut.Images[0].ImageId) + slog.Info("System AMI resolved for LB VMs", "amiId", amiID, "name", aws.StringValue(imagesOut.Images[0].Name)) + return amiID, nil }) } diff --git a/spinifex/handlers/elbv2/service_impl.go b/spinifex/handlers/elbv2/service_impl.go index 9cc1f5d8a..0d93a2828 100644 --- a/spinifex/handlers/elbv2/service_impl.go +++ b/spinifex/handlers/elbv2/service_impl.go @@ -93,14 +93,14 @@ type ELBv2ServiceImpl struct { MgmtRouteTarget string // AWSGW bind IP to route via mgmt NIC nodeID string region string - systemAMI string // AMI ID for system VMs (ALB VMs); resolved lazily via systemAMIFunc - systemAMIFunc func() string // returns the current system AMI ID (queries image store) - systemAMIMu sync.Mutex // guards lazy resolution of systemAMI - systemAMIResolved bool // true once systemAMI has been resolved to a non-empty value - systemInstanceType string // instance type for system VMs; resolved lazily via systemInstanceTypeFunc - systemInstanceTypeFunc func() string // returns the smallest available instance type - systemInstanceTypeMu sync.Mutex // guards lazy resolution of systemInstanceType - systemInstanceTypeResolved bool // true once systemInstanceType has been resolved to a non-empty value + systemAMI string // AMI ID for system VMs (ALB VMs); resolved lazily via systemAMIFunc + systemAMIFunc func() (string, error) // returns the current system AMI ID (queries image store) + systemAMIMu sync.Mutex // guards lazy resolution of systemAMI + systemAMIResolved bool // true once systemAMI has been resolved to a non-empty value + systemInstanceType string // instance type for system VMs; resolved lazily via systemInstanceTypeFunc + systemInstanceTypeFunc func() string // returns the smallest available instance type + systemInstanceTypeMu sync.Mutex // guards lazy resolution of systemInstanceType + systemInstanceTypeResolved bool // true once systemInstanceType has been resolved to a non-empty value ctx context.Context cancel context.CancelFunc hc *healthChecker @@ -147,7 +147,7 @@ func (s *ELBv2ServiceImpl) Close() { // SetSystemAMIFunc sets a function that resolves the current system AMI ID. // This is called at request time so the AMI is discovered even if imported // after the daemon starts. -func (s *ELBv2ServiceImpl) SetSystemAMIFunc(fn func() string) { +func (s *ELBv2ServiceImpl) SetSystemAMIFunc(fn func() (string, error)) { s.systemAMIMu.Lock() defer s.systemAMIMu.Unlock() s.systemAMIFunc = fn @@ -156,21 +156,25 @@ func (s *ELBv2ServiceImpl) SetSystemAMIFunc(fn func() string) { } // getSystemAMI returns the system AMI ID, resolving it lazily if needed. -// Only caches non-empty results so the resolver retries when the image -// has not been imported yet. -func (s *ELBv2ServiceImpl) getSystemAMI() string { +// Caches only successful resolutions so the resolver retries after errors. +func (s *ELBv2ServiceImpl) getSystemAMI() (string, error) { s.systemAMIMu.Lock() defer s.systemAMIMu.Unlock() if s.systemAMIResolved { - return s.systemAMI + return s.systemAMI, nil } - if s.systemAMIFunc != nil { - s.systemAMI = s.systemAMIFunc() + if s.systemAMIFunc == nil { + return "", nil } - if s.systemAMI != "" { + ami, err := s.systemAMIFunc() + if err != nil { + return "", err + } + if ami != "" { + s.systemAMI = ami s.systemAMIResolved = true } - return s.systemAMI + return ami, nil } // SetSystemInstanceTypeFunc sets a function that resolves the smallest available @@ -608,7 +612,16 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp var albVPCIP string var hostPorts map[int]int var launchFailed bool - if s.InstanceLauncher != nil && s.getSystemAMI() != "" && len(eniIDs) > 0 && len(subnets) > 0 { + if s.InstanceLauncher != nil && len(eniIDs) > 0 && len(subnets) > 0 { + systemAMI, amiErr := s.getSystemAMI() + if amiErr != nil { + slog.Error("CreateLoadBalancer: cannot resolve LB system AMI", "lbId", lbID, "err", amiErr) + return nil, errors.New(awserrors.ErrorServerInternal) + } + if systemAMI == "" { + slog.Error("CreateLoadBalancer: LB system AMI not resolved", "lbId", lbID) + return nil, errors.New(awserrors.ErrorServerInternal) + } // Resolve MAC/IP for every ENI in one describe call. eniDetails := make(map[string]*ec2.NetworkInterface, len(eniIDs)) if s.VPCService != nil { @@ -657,7 +670,7 @@ func (s *ELBv2ServiceImpl) CreateLoadBalancer(input *elbv2.CreateLoadBalancerInp } else { launchInput := &SystemInstanceInput{ InstanceType: s.getSystemInstanceType(), - ImageID: s.getSystemAMI(), + ImageID: systemAMI, SubnetID: subnets[0], UserData: userData, ENIID: eniIDs[0], diff --git a/spinifex/handlers/elbv2/service_impl_test.go b/spinifex/handlers/elbv2/service_impl_test.go index 71f4bec9f..2ca7692b8 100644 --- a/spinifex/handlers/elbv2/service_impl_test.go +++ b/spinifex/handlers/elbv2/service_impl_test.go @@ -1,6 +1,7 @@ package handlers_elbv2 import ( + "errors" "sort" "sync" "testing" @@ -1086,7 +1087,7 @@ func TestCreateLoadBalancer_InternetFacingScheme_PassesSchemeToLauncher(t *testi }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{ Name: aws.String("public-alb"), @@ -1111,7 +1112,7 @@ func TestCreateLoadBalancer_InternalScheme_PassesSchemeToLauncher(t *testing.T) }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{ Name: aws.String("private-alb"), @@ -1516,41 +1517,53 @@ func TestSetSystemInstanceTypeFunc(t *testing.T) { func TestGetSystemAMI(t *testing.T) { svc := setupTestService(t) - // Before setting, should return empty - assert.Empty(t, svc.getSystemAMI()) + // Before setting, should return empty with no error + ami, err := svc.getSystemAMI() + require.NoError(t, err) + assert.Empty(t, ami) // Set the resolver - svc.SetSystemAMIFunc(func() string { return "ami-system-001" }) - assert.Equal(t, "ami-system-001", svc.getSystemAMI()) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-system-001", nil }) + ami, err = svc.getSystemAMI() + require.NoError(t, err) + assert.Equal(t, "ami-system-001", ami) // Once resolved, caches the value - svc.systemAMIFunc = func() string { return "ami-system-002" } - assert.Equal(t, "ami-system-001", svc.getSystemAMI()) + svc.systemAMIFunc = func() (string, error) { return "ami-system-002", nil } + ami, err = svc.getSystemAMI() + require.NoError(t, err) + assert.Equal(t, "ami-system-001", ami) } -func TestGetSystemAMI_RetryOnEmpty(t *testing.T) { +func TestGetSystemAMI_RetryOnError(t *testing.T) { svc := setupTestService(t) calls := 0 - svc.SetSystemAMIFunc(func() string { + svc.SetSystemAMIFunc(func() (string, error) { calls++ if calls < 3 { - return "" // simulate image not imported yet + return "", errors.New("LB system image not imported") } - return "ami-system-001" + return "ami-system-001", nil }) - // First two calls return empty — should NOT be cached - assert.Empty(t, svc.getSystemAMI()) - assert.Empty(t, svc.getSystemAMI()) + // First two calls error — must NOT be cached + _, err := svc.getSystemAMI() + require.Error(t, err) + _, err = svc.getSystemAMI() + require.Error(t, err) assert.Equal(t, 2, calls) - // Third call finds the image — should be cached from now on - assert.Equal(t, "ami-system-001", svc.getSystemAMI()) + // Third call succeeds — cached from now on + ami, err := svc.getSystemAMI() + require.NoError(t, err) + assert.Equal(t, "ami-system-001", ami) assert.Equal(t, 3, calls) - // Subsequent calls return cached value without calling the func again - assert.Equal(t, "ami-system-001", svc.getSystemAMI()) + // Subsequent calls return cached value without re-invoking + ami, err = svc.getSystemAMI() + require.NoError(t, err) + assert.Equal(t, "ami-system-001", ami) assert.Equal(t, 3, calls) } @@ -1579,12 +1592,13 @@ func TestGetSystemInstanceType_RetryOnEmpty(t *testing.T) { func TestGetSystemAMI_Concurrent(t *testing.T) { svc := setupTestService(t) - svc.SetSystemAMIFunc(func() string { return "ami-system-001" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-system-001", nil }) var wg sync.WaitGroup for range 50 { wg.Go(func() { - got := svc.getSystemAMI() + got, err := svc.getSystemAMI() + assert.NoError(t, err) assert.Equal(t, "ami-system-001", got) }) } diff --git a/spinifex/handlers/elbv2/service_impl_vpc_test.go b/spinifex/handlers/elbv2/service_impl_vpc_test.go index e0fb4c89f..80cb0c0be 100644 --- a/spinifex/handlers/elbv2/service_impl_vpc_test.go +++ b/spinifex/handlers/elbv2/service_impl_vpc_test.go @@ -1,11 +1,13 @@ package handlers_elbv2 import ( + "errors" "testing" "github.com/aws/aws-sdk-go/aws" "github.com/aws/aws-sdk-go/service/ec2" "github.com/aws/aws-sdk-go/service/elbv2" + "github.com/mulgadc/spinifex/spinifex/awserrors" "github.com/mulgadc/spinifex/spinifex/config" handlers_ec2_vpc "github.com/mulgadc/spinifex/spinifex/handlers/ec2/vpc" "github.com/mulgadc/spinifex/spinifex/testutil" @@ -157,7 +159,7 @@ func TestCreateLoadBalancer_MultiSubnet_AllENIsPassedToLauncher(t *testing.T) { }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) svc.GatewayURL = "https://10.0.0.1:9999" svc.SystemAccessKey = "AKID" svc.SystemSecretKey = "SECRET" @@ -278,7 +280,7 @@ func TestCreateLoadBalancer_InternetFacing_AllocatesPublicIP(t *testing.T) { }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) svc.GatewayURL = "https://10.0.0.1:9999" svc.SystemAccessKey = "AKID" svc.SystemSecretKey = "SECRET" @@ -327,7 +329,7 @@ func TestCreateLoadBalancer_Internal_NoPublicIP(t *testing.T) { }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) svc.GatewayURL = "https://10.0.0.1:9999" svc.SystemAccessKey = "AKID" svc.SystemSecretKey = "SECRET" @@ -368,7 +370,7 @@ func TestCreateLoadBalancer_NLB_Internal_NoPublicIP(t *testing.T) { }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-nlb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-nlb-test", nil }) svc.GatewayURL = "https://10.0.0.1:9999" svc.SystemAccessKey = "AKID" svc.SystemSecretKey = "SECRET" @@ -417,7 +419,7 @@ func TestDeleteLoadBalancer_TerminatesVM_WithPublicIP(t *testing.T) { terminateDone: make(chan struct{}), } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) svc.GatewayURL = "https://10.0.0.1:9999" svc.SystemAccessKey = "AKID" svc.SystemSecretKey = "SECRET" @@ -465,7 +467,7 @@ func TestDescribeLoadBalancers_InternetFacing_IncludesPublicIP(t *testing.T) { }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) svc.GatewayURL = "https://10.0.0.1:9999" svc.SystemAccessKey = "AKID" svc.SystemSecretKey = "SECRET" @@ -515,7 +517,7 @@ func TestDescribeLoadBalancers_Internal_NoPublicIP(t *testing.T) { }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) svc.GatewayURL = "https://10.0.0.1:9999" svc.SystemAccessKey = "AKID" svc.SystemSecretKey = "SECRET" @@ -554,7 +556,7 @@ func TestCreateLoadBalancer_LaunchFailure_SetsStateFailed(t *testing.T) { launchErr: assert.AnError, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) svc.GatewayURL = "https://10.0.0.1:9999" svc.SystemAccessKey = "AKID" svc.SystemSecretKey = "SECRET" @@ -583,7 +585,7 @@ func TestCreateLoadBalancer_MissingCredentials_SetsStateFailed(t *testing.T) { }, } svc.InstanceLauncher = mock - svc.SetSystemAMIFunc(func() string { return "ami-alb-test" }) + svc.SetSystemAMIFunc(func() (string, error) { return "ami-alb-test", nil }) // Deliberately NOT setting credentials out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{ @@ -639,3 +641,40 @@ func TestENI_RequesterManagedFlag(t *testing.T) { } } } + +// TestCreateLoadBalancer_AMIResolverError_ReturnsServerInternal verifies that +// when the launch path would fire (launcher + networking present) but the LB +// system AMI cannot be resolved, CreateLoadBalancer returns ServerInternal and +// does not persist a record or invoke the launcher. +func TestCreateLoadBalancer_AMIResolverError_ReturnsServerInternal(t *testing.T) { + svc, vpcSvc := setupTestServiceWithVPC(t) + + subnets, err := vpcSvc.DescribeSubnets(&ec2.DescribeSubnetsInput{}, testAccountID) + require.NoError(t, err) + subnetID := *subnets.Subnets[0].SubnetId + + mock := &mockSystemInstanceLauncher{ + launchResult: &SystemInstanceOutput{InstanceID: "i-should-not-launch"}, + } + svc.InstanceLauncher = mock + svc.SetSystemAMIFunc(func() (string, error) { + return "", errors.New("LB system image not imported") + }) + svc.GatewayURL = "https://10.0.0.1:9999" + svc.SystemAccessKey = "AKID" + svc.SystemSecretKey = "SECRET" + + out, err := svc.CreateLoadBalancer(&elbv2.CreateLoadBalancerInput{ + Name: aws.String("ami-err-alb"), + Subnets: []*string{aws.String(subnetID)}, + }, testAccountID) + + require.Error(t, err) + assert.Equal(t, awserrors.ErrorServerInternal, err.Error()) + assert.Nil(t, out) + assert.Empty(t, mock.launchCalls, "launcher must not be invoked when AMI resolver errors") + + lbs, err := svc.store.ListLoadBalancers() + require.NoError(t, err) + assert.Empty(t, lbs, "no LB record should be persisted when AMI resolver errors") +}