From 24617792b8869d920486aba72b4eca9824e0fb38 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Sun, 24 May 2026 22:41:16 +0100 Subject: [PATCH 1/2] feat: add native engine support for AWS Lambda bundle and deploy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Gates the Lambda binary download, runtime, handler, env vars and SnapStart on engine type so the native imposter-go binary can be bundled and deployed alongside the existing JVM flavour. When the engine type is not specified but a version is, the engine is derived from the version (v5+ → native). Adds an --architecture flag to `imposter bundle` so per-architecture native binaries can be selected, with the value threaded through to the binary download and cache paths. --- cmd/bundle.go | 15 +++- cmd/up.go | 2 +- internal/engine/awslambda/binary.go | 74 ++++++++++++++++--- internal/engine/awslambda/bundle.go | 11 ++- internal/engine/awslambda/library_provider.go | 6 ++ internal/engine/builder.go | 31 +++++++- internal/engine/builder_test.go | 39 ++++++++++ internal/engine/version.go | 40 +++++++++- internal/engine/version_test.go | 25 +++++++ internal/remote/awslambda/config.go | 9 +++ internal/remote/awslambda/deploy.go | 50 +++++++------ internal/remote/awslambda/flavour.go | 56 ++++++++++++++ 12 files changed, 310 insertions(+), 48 deletions(-) create mode 100644 internal/remote/awslambda/flavour.go diff --git a/cmd/bundle.go b/cmd/bundle.go index df7cf0b..168c8bf 100644 --- a/cmd/bundle.go +++ b/cmd/bundle.go @@ -18,18 +18,21 @@ package cmd import ( "fmt" - config2 "github.com/imposter-project/imposter-cli/internal/config" - "github.com/imposter-project/imposter-cli/internal/engine" - "github.com/spf13/cobra" "os" "path/filepath" "time" + + config2 "github.com/imposter-project/imposter-cli/internal/config" + "github.com/imposter-project/imposter-cli/internal/engine" + "github.com/imposter-project/imposter-cli/internal/engine/awslambda" + "github.com/spf13/cobra" ) var bundleFlags = struct { engineType string engineVersion string output string + architecture string }{} // bundleCmd represents the bundle command @@ -58,7 +61,7 @@ If CONFIG_DIR is not specified, the current working directory is used.`, // Search for CLI config files in the mock config dir. config2.MergeCliConfigIfExists(configDir) - engineType := engine.GetConfiguredType(bundleFlags.engineType) + engineType := engine.GetConfiguredTypeWithVersion(bundleFlags.engineType, bundleFlags.engineVersion) lib := engine.GetLibrary(engineType) if lib.IsSealedDistro() { @@ -75,6 +78,7 @@ func init() { bundleCmd.Flags().StringVarP(&bundleFlags.output, "output", "o", "", "The destination to write the bundle to. If using the 'docker' engine type, this must be a valid image name. Otherwise, this must be a path to a writeable file. If not specified, a name is generated.") bundleCmd.Flags().StringVarP(&bundleFlags.engineType, "engine-type", "t", "", "Imposter engine type (valid: awslambda,docker,jvm)") bundleCmd.Flags().StringVarP(&bundleFlags.engineVersion, "version", "v", "", "Imposter engine version (default \"latest\")") + bundleCmd.Flags().StringVarP(&bundleFlags.architecture, "architecture", "a", awslambda.DefaultLambdaArch, "Target CPU architecture for the awslambda engine bundle (amd64 or arm64). Ignored by other engine types.") _ = bundleCmd.MarkFlagRequired("engine-type") registerEngineTypeCompletions(bundleCmd, engine.EngineTypeAwsLambda) @@ -107,6 +111,9 @@ func getBundleDest(engineType engine.EngineType) string { func bundle(lib *engine.EngineLibrary, version string, configDir string, dest string) { provider := (*lib).GetProvider(version) + if lambdaProv, ok := provider.(*awslambda.LambdaProvider); ok { + lambdaProv.Architecture = bundleFlags.architecture + } logger.Debugf("creating %s bundle %s using version %s", provider.GetEngineType(), configDir, version) err := provider.Provide(engine.PullIfNotPresent) diff --git a/cmd/up.go b/cmd/up.go index 4dcf60a..fb9579c 100644 --- a/cmd/up.go +++ b/cmd/up.go @@ -83,7 +83,7 @@ If CONFIG_DIR is not specified, the current working directory is used.`, pullPolicy = engine.PullIfNotPresent } - engineType := engine.GetConfiguredType(upFlags.engineType) + engineType := engine.GetConfiguredTypeWithVersion(upFlags.engineType, upFlags.engineVersion) lib := engine.GetLibrary(engineType) var version string diff --git a/internal/engine/awslambda/binary.go b/internal/engine/awslambda/binary.go index 976326a..e967a27 100644 --- a/internal/engine/awslambda/binary.go +++ b/internal/engine/awslambda/binary.go @@ -2,27 +2,81 @@ package awslambda import ( "fmt" - library2 "github.com/imposter-project/imposter-cli/internal/library" - "github.com/spf13/viper" "os" "path/filepath" -) -var downloadConfig = library2.NewDownloadConfig( - "https://github.com/imposter-project/imposter-jvm-engine/releases/latest/download", - "https://github.com/imposter-project/imposter-jvm-engine/releases/download/v%v", - false, + "github.com/imposter-project/imposter-cli/internal/engine" + library2 "github.com/imposter-project/imposter-cli/internal/library" + "github.com/spf13/viper" ) -func checkOrDownloadBinary(version string) (string, error) { +// DefaultLambdaArch is the architecture assumed when a caller does not +// specify one. It matches AWS Lambda's own default architecture (x86_64, +// expressed as the Go arch name "amd64") so unconfigured bundles still +// produce a deployable artefact. +const DefaultLambdaArch = "amd64" + +// lambdaBinarySpec describes the source and local naming for an AWS Lambda +// deployment binary. The JVM-engine flavour ships a single arch-agnostic zip +// from imposter-jvm-engine; the native (imposter-go) flavour ships separate +// per-architecture zips from imposter-go. +type lambdaBinarySpec struct { + downloadConfig library2.DownloadConfig + remoteFile func(arch string) string + cacheFile func(arch, version string) string +} + +var jvmLambdaSpec = lambdaBinarySpec{ + downloadConfig: library2.NewDownloadConfig( + "https://github.com/imposter-project/imposter-jvm-engine/releases/latest/download", + "https://github.com/imposter-project/imposter-jvm-engine/releases/download/v%v", + false, + ), + remoteFile: func(string) string { return "imposter-awslambda.zip" }, + cacheFile: func(_, version string) string { + return fmt.Sprintf("imposter-awslambda-%s.zip", version) + }, +} + +var nativeLambdaSpec = lambdaBinarySpec{ + downloadConfig: library2.NewDownloadConfig( + "https://github.com/imposter-project/imposter-go/releases/latest/download", + "https://github.com/imposter-project/imposter-go/releases/download/v%v", + false, + ), + remoteFile: func(arch string) string { + return fmt.Sprintf("imposter-awslambda_%s.zip", arch) + }, + cacheFile: func(arch, version string) string { + return fmt.Sprintf("imposter-go-awslambda-%s-%s.zip", arch, version) + }, +} + +// specForVersion picks the AWS Lambda binary spec for the given engine +// version. 5.x and later use the native (imposter-go) flavour; everything +// else — including the empty/"latest" alias and unparseable values — falls +// back to the JVM flavour, matching the project-wide default engine. +func specForVersion(version string) lambdaBinarySpec { + if engine.DeriveEngineTypeFromVersion(version) == engine.EngineTypeNative { + return nativeLambdaSpec + } + return jvmLambdaSpec +} + +func checkOrDownloadBinary(version string, arch string) (string, error) { + if arch == "" { + arch = DefaultLambdaArch + } binFilePath := viper.GetString("lambda.binary") if binFilePath == "" { + spec := specForVersion(version) + binCachePath, err := ensureBinCache() if err != nil { logger.Fatal(err) } - binFilePath = filepath.Join(binCachePath, fmt.Sprintf("imposter-awslambda-%v.zip", version)) + binFilePath = filepath.Join(binCachePath, spec.cacheFile(arch, version)) if _, err := os.Stat(binFilePath); err != nil { if !os.IsNotExist(err) { @@ -34,7 +88,7 @@ func checkOrDownloadBinary(version string) (string, error) { return binFilePath, nil } - if err := library2.DownloadBinary(downloadConfig, binFilePath, "imposter-awslambda.zip", version); err != nil { + if err := library2.DownloadBinary(spec.downloadConfig, binFilePath, spec.remoteFile(arch), version); err != nil { return "", fmt.Errorf("failed to fetch lambda binary: %v", err) } } diff --git a/internal/engine/awslambda/bundle.go b/internal/engine/awslambda/bundle.go index a6f4015..9c07b23 100644 --- a/internal/engine/awslambda/bundle.go +++ b/internal/engine/awslambda/bundle.go @@ -28,7 +28,7 @@ import ( ) func (p *LambdaProvider) Bundle(configDir string, dest string) error { - deploymentPackage, err := CreateDeploymentPackage(p.Version, configDir) + deploymentPackage, err := CreateDeploymentPackage(p.Version, configDir, p.Architecture) if err != nil { return fmt.Errorf("failed to create bundle: %v", err) } @@ -44,8 +44,13 @@ func (p *LambdaProvider) Bundle(configDir string, dest string) error { return nil } -func CreateDeploymentPackage(version string, dir string) (*[]byte, error) { - binaryPath, err := checkOrDownloadBinary(version) +// CreateDeploymentPackage assembles the AWS Lambda zip for the given engine +// version, embedding the configuration directory contents. arch selects the +// underlying binary's CPU architecture (Go-style: "amd64" or "arm64") for +// engine flavours whose binaries are arch-specific (the native engine); +// callers may pass "" to fall back to DefaultLambdaArch. +func CreateDeploymentPackage(version string, dir string, arch string) (*[]byte, error) { + binaryPath, err := checkOrDownloadBinary(version, arch) if err != nil { return nil, err } diff --git a/internal/engine/awslambda/library_provider.go b/internal/engine/awslambda/library_provider.go index bd58b49..a030b7a 100644 --- a/internal/engine/awslambda/library_provider.go +++ b/internal/engine/awslambda/library_provider.go @@ -25,6 +25,12 @@ type LambdaLibrary struct{} type LambdaProvider struct { engine.EngineMetadata + + // Architecture selects the target CPU architecture for the bundled + // binary (Go-style: "amd64" or "arm64"). It is only consulted by engine + // flavours that ship per-architecture binaries (the native engine). If + // left empty, CreateDeploymentPackage falls back to DefaultLambdaArch. + Architecture string } var logger = logging.GetLogger() diff --git a/internal/engine/builder.go b/internal/engine/builder.go index df727c6..14c665c 100644 --- a/internal/engine/builder.go +++ b/internal/engine/builder.go @@ -137,12 +137,35 @@ func GetConfiguredType(override string) EngineType { return GetConfiguredTypeWithDefault(override, defaultEngineType) } +// GetConfiguredTypeWithVersion is like GetConfiguredType but also takes an +// engine-version override so that a pinned version can imply an engine type +// when no explicit one is configured. CLI commands that expose a --version +// flag should pass its value as versionOverride. +func GetConfiguredTypeWithVersion(typeOverride string, versionOverride string) EngineType { + return getConfiguredType(typeOverride, versionOverride, defaultEngineType) +} + func GetConfiguredTypeWithDefault(override string, defaultType EngineType) EngineType { - return normaliseEngineType(EngineType(stringutil.GetFirstNonEmpty( - override, + return getConfiguredType(override, "", defaultType) +} + +func getConfiguredType(typeOverride string, versionOverride string, defaultType EngineType) EngineType { + explicit := stringutil.GetFirstNonEmpty( + typeOverride, viper.GetString("engine"), - string(defaultType), - ))) + ) + if explicit != "" { + return normaliseEngineType(EngineType(explicit)) + } + // No explicit engine type configured. If the user has pinned a specific + // engine version we can sometimes derive the engine type from it (e.g. + // 5.x implies the native engine). "latest" intentionally does not derive + // — callers keep the supplied default until "latest" is re-pointed at v5. + version := stringutil.GetFirstNonEmpty(versionOverride, viper.GetString("version")) + if derived := DeriveEngineTypeFromVersion(version); derived != EngineTypeNone { + return derived + } + return defaultType } func GetConfiguredVersion(engineType EngineType, override string, allowCached bool) string { diff --git a/internal/engine/builder_test.go b/internal/engine/builder_test.go index a87f695..bd70a73 100644 --- a/internal/engine/builder_test.go +++ b/internal/engine/builder_test.go @@ -67,6 +67,45 @@ func TestGetConfiguredType(t *testing.T) { } } +func TestGetConfiguredTypeWithVersion(t *testing.T) { + type args struct { + typeOverride string + versionOverride string + } + tests := []struct { + name string + args args + configureType string + configureVersion string + want EngineType + }{ + {name: "explicit type wins over version", args: args{typeOverride: "docker", versionOverride: "5.0.0"}, want: EngineTypeDockerCore}, + {name: "configured type wins over version", args: args{versionOverride: "5.0.0"}, configureType: "jvm", want: EngineTypeJvmSingleJar}, + {name: "5.x version override derives native", args: args{versionOverride: "5.0.0"}, want: EngineTypeNative}, + {name: "5.x configured version derives native", configureVersion: "5.2.3", want: EngineTypeNative}, + {name: "4.x version keeps default", args: args{versionOverride: "4.9.0"}, want: defaultEngineType}, + {name: "latest keeps default", args: args{versionOverride: "latest"}, want: defaultEngineType}, + {name: "no version, no type, returns default", want: defaultEngineType}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.configureType != "" { + viper.Set("engine", tt.configureType) + } + if tt.configureVersion != "" { + viper.Set("version", tt.configureVersion) + } + t.Cleanup(func() { + viper.Set("engine", nil) + viper.Set("version", nil) + }) + if got := GetConfiguredTypeWithVersion(tt.args.typeOverride, tt.args.versionOverride); got != tt.want { + t.Errorf("GetConfiguredTypeWithVersion() = %v, want %v", got, tt.want) + } + }) + } +} + func TestSanitiseVersionOutput(t *testing.T) { type args struct { s string diff --git a/internal/engine/version.go b/internal/engine/version.go index ef91c33..a010bcb 100644 --- a/internal/engine/version.go +++ b/internal/engine/version.go @@ -36,17 +36,51 @@ func ResolveLatestToVersion(engineType EngineType, allowCached bool) (string, er return latest, nil } +// parseMajorVersion returns the major component of the given engine version, +// or (0, false) if the version cannot be parsed as semver. Callers decide how +// to treat the unparseable case (typically: assume the modern 5.x+ line). +func parseMajorVersion(version string) (int64, bool) { + v, err := semver.NewVersion(version) + if err != nil { + return 0, false + } + return v.Major, true +} + // UsesEnvConfig reports whether the given engine version expects its config // directory and listen port via IMPOSTER_CONFIG_DIR and IMPOSTER_PORT env // vars (5.x and later) rather than --configDir and --listenPort CLI flags // (4.x and earlier). Unparseable versions (e.g. "dev") default to the env-var // form. func UsesEnvConfig(version string) bool { - v, err := semver.NewVersion(version) - if err != nil { + major, ok := parseMajorVersion(version) + if !ok { return true } - return v.Major >= 5 + return major >= 5 +} + +// DeriveEngineTypeFromVersion returns the engine type implied by an explicit +// engine version, or EngineTypeNone if no derivation can be made. Callers +// should fall back to their configured/default engine type when this returns +// EngineTypeNone. +// +// Versions 5.x and later resolve to EngineTypeNative. Earlier versions, the +// "latest" alias, the empty string, and unparseable values all return +// EngineTypeNone so the default engine continues to apply. (Future: "latest" +// will be re-pointed at v5 and so will yield EngineTypeNative.) +func DeriveEngineTypeFromVersion(version string) EngineType { + if version == "" || version == "latest" { + return EngineTypeNone + } + major, ok := parseMajorVersion(version) + if !ok { + return EngineTypeNone + } + if major >= 5 { + return EngineTypeNative + } + return EngineTypeNone } func GetHighestVersion(engines []EngineMetadata) string { diff --git a/internal/engine/version_test.go b/internal/engine/version_test.go index 7f5460b..9b7f37c 100644 --- a/internal/engine/version_test.go +++ b/internal/engine/version_test.go @@ -42,3 +42,28 @@ func TestUsesEnvConfig(t *testing.T) { }) } } + +func TestDeriveEngineTypeFromVersion(t *testing.T) { + tests := []struct { + name string + version string + want EngineType + }{ + {name: "3.x has no derivation", version: "3.40.0", want: EngineTypeNone}, + {name: "4.x has no derivation", version: "4.9.1", want: EngineTypeNone}, + {name: "5.0.0 derives native", version: "5.0.0", want: EngineTypeNative}, + {name: "5.x derives native", version: "5.2.3", want: EngineTypeNative}, + {name: "6.x derives native", version: "6.0.0", want: EngineTypeNative}, + {name: "5.x pre-release derives native", version: "5.0.0-beta.1", want: EngineTypeNative}, + {name: "latest keeps default", version: "latest", want: EngineTypeNone}, + {name: "empty keeps default", version: "", want: EngineTypeNone}, + {name: "unparseable keeps default", version: "dev", want: EngineTypeNone}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := DeriveEngineTypeFromVersion(tt.version); got != tt.want { + t.Errorf("DeriveEngineTypeFromVersion(%q) = %v, want %v", tt.version, got, tt.want) + } + }) + } +} diff --git a/internal/remote/awslambda/config.go b/internal/remote/awslambda/config.go index cd88173..b84c4ef 100644 --- a/internal/remote/awslambda/config.go +++ b/internal/remote/awslambda/config.go @@ -23,6 +23,15 @@ const ( LambdaArchitectureArm64 LambdaArchitecture = "arm64" ) +// goArchFor translates an AWS Lambda architecture name to the Go arch name +// used by per-architecture binary releases (e.g. imposter-go's Lambda zips). +func goArchFor(a LambdaArchitecture) string { + if a == LambdaArchitectureArm64 { + return "arm64" + } + return "amd64" +} + const remoteType = "awslambda" const defaultArchitecture = LambdaArchitectureX86_64 const defaultRegion = "us-east-1" diff --git a/internal/remote/awslambda/deploy.go b/internal/remote/awslambda/deploy.go index 3168e35..1ab9c77 100644 --- a/internal/remote/awslambda/deploy.go +++ b/internal/remote/awslambda/deploy.go @@ -34,7 +34,8 @@ func (m LambdaRemote) Deploy() error { } engineVersion := engine.GetConfiguredVersion(engine.EngineTypeAwsLambda, m.Config[configKeyEngineVersion], true) - zipContents, err := awslambda.CreateDeploymentPackage(engineVersion, m.Dir) + flavour := flavourForVersion(engineVersion) + zipContents, err := awslambda.CreateDeploymentPackage(engineVersion, m.Dir, goArchFor(m.getArchitecture())) if err != nil { logger.Fatal(err) } @@ -56,6 +57,10 @@ func (m LambdaRemote) Deploy() error { } snapStart := stringutil.ToBool(m.Config[configKeySnapStart]) + if snapStart && !flavour.supportsSnapStart { + logger.Warnf("SnapStart is not supported by the %s lambda runtime — ignoring", flavour.runtime) + snapStart = false + } funcArn, err := ensureFunctionExists( svc, region, @@ -65,6 +70,7 @@ func (m LambdaRemote) Deploy() error { m.getArchitecture(), location, snapStart, + flavour, ) if err != nil { return err @@ -345,6 +351,7 @@ func ensureFunctionExists( arch LambdaArchitecture, location codeLocation, snapStart bool, + flavour lambdaFlavour, ) (string, error) { var funcArn string @@ -361,6 +368,7 @@ func ensureFunctionExists( arch, location, snapStart, + flavour, ) if err != nil { return "", err @@ -372,8 +380,10 @@ func ensureFunctionExists( } else { funcArn = *result.Configuration.FunctionArn - if err = ensureSnapStart(svc, funcArn, snapStart); err != nil { - return "", err + if flavour.supportsSnapStart { + if err = ensureSnapStart(svc, funcArn, snapStart); err != nil { + return "", err + } } if err = updateFunctionCode(svc, funcArn, location); err != nil { return "", err @@ -404,28 +414,29 @@ func createFunction( arch LambdaArchitecture, location codeLocation, snapStart bool, + flavour lambdaFlavour, ) (arn string, err error) { logger.Debugf("creating function: %s in region: %s", funcName, region) - var desiredConfig lambdatypes.SnapStartApplyOn - if snapStart { - desiredConfig = lambdatypes.SnapStartApplyOnPublishedVersions - } else { - desiredConfig = lambdatypes.SnapStartApplyOnNone - } - input := &lambda.CreateFunctionInput{ FunctionName: aws.String(funcName), - Handler: aws.String("io.gatehill.imposter.awslambda.HandlerV2"), + Handler: aws.String(flavour.handler), MemorySize: aws.Int32(memoryMb), Role: aws.String(roleArn), - Runtime: lambdatypes.RuntimeJava11, + Runtime: flavour.runtime, Architectures: []lambdatypes.Architecture{lambdatypes.Architecture(arch)}, - Environment: buildEnv(), + Environment: flavour.buildEnv(), Code: &lambdatypes.FunctionCode{}, - SnapStart: &lambdatypes.SnapStart{ - ApplyOn: desiredConfig, - }, + } + + if flavour.supportsSnapStart { + var desiredConfig lambdatypes.SnapStartApplyOn + if snapStart { + desiredConfig = lambdatypes.SnapStartApplyOnPublishedVersions + } else { + desiredConfig = lambdatypes.SnapStartApplyOnNone + } + input.SnapStart = &lambdatypes.SnapStart{ApplyOn: desiredConfig} } if location.bucket != "" { @@ -495,13 +506,6 @@ func (m LambdaRemote) getAwsRegion() string { panic("no AWS default region set") } -func buildEnv() *lambdatypes.Environment { - env := make(map[string]string) - env["IMPOSTER_CONFIG_DIR"] = "/var/task/config" - env["JAVA_TOOL_OPTIONS"] = "-XX:+TieredCompilation -XX:TieredStopAtLevel=1" - return &lambdatypes.Environment{Variables: env} -} - func (m LambdaRemote) deleteFunction(funcArn string, svc *lambda.Client) error { logger.Tracef("deleting function: %s", funcArn) _, err := svc.DeleteFunction(ctx, &lambda.DeleteFunctionInput{ diff --git a/internal/remote/awslambda/flavour.go b/internal/remote/awslambda/flavour.go new file mode 100644 index 0000000..99532bf --- /dev/null +++ b/internal/remote/awslambda/flavour.go @@ -0,0 +1,56 @@ +package awslambda + +import ( + lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types" + "github.com/imposter-project/imposter-cli/internal/engine" +) + +// lambdaFlavour captures the runtime-specific differences between the +// JVM-engine AWS Lambda binary (imposter-jvm-engine) and the native +// imposter-go binary that runs on a custom Amazon Linux runtime. +type lambdaFlavour struct { + runtime lambdatypes.Runtime + handler string + envVars map[string]string + supportsSnapStart bool +} + +var jvmLambdaFlavour = lambdaFlavour{ + runtime: lambdatypes.RuntimeJava11, + handler: "io.gatehill.imposter.awslambda.HandlerV2", + envVars: map[string]string{ + "IMPOSTER_CONFIG_DIR": "/var/task/config", + "JAVA_TOOL_OPTIONS": "-XX:+TieredCompilation -XX:TieredStopAtLevel=1", + }, + supportsSnapStart: true, +} + +// nativeLambdaFlavour targets the imposter-go binary deployed on the +// provided.al2023 custom runtime. The handler must be named "bootstrap" by +// AWS convention (the zip's executable is invoked directly). SnapStart is a +// JVM-only feature and is not configured for this flavour. +var nativeLambdaFlavour = lambdaFlavour{ + runtime: lambdatypes.RuntimeProvidedal2023, + handler: "bootstrap", + envVars: map[string]string{ + "IMPOSTER_CONFIG_DIR": "/var/task/config", + }, + supportsSnapStart: false, +} + +// flavourForVersion picks the Lambda flavour implied by the engine version, +// matching the binary-download gating in engine/awslambda/binary.go. +func flavourForVersion(version string) lambdaFlavour { + if engine.DeriveEngineTypeFromVersion(version) == engine.EngineTypeNative { + return nativeLambdaFlavour + } + return jvmLambdaFlavour +} + +func (f lambdaFlavour) buildEnv() *lambdatypes.Environment { + env := make(map[string]string, len(f.envVars)) + for k, v := range f.envVars { + env[k] = v + } + return &lambdatypes.Environment{Variables: env} +} From 34f6b09028d943a26739fb4d6b8c83fd587aec84 Mon Sep 17 00:00:00 2001 From: Pete Cornish Date: Mon, 25 May 2026 12:01:06 +0100 Subject: [PATCH 2/2] fix(awslambda): repackage imposter-go tarball as bootstrap zip The imposter-go releases ship per-arch `imposter-go_linux_.tar.gz` artefacts containing a plain `imposter-go` binary, not a pre-built Lambda zip. Download that tarball, extract the binary, and write a Lambda-ready zip containing a single executable `bootstrap` entry for the provided.al2023 custom runtime. --- internal/engine/awslambda/binary.go | 129 +++++++++++++++++++++++----- 1 file changed, 107 insertions(+), 22 deletions(-) diff --git a/internal/engine/awslambda/binary.go b/internal/engine/awslambda/binary.go index e967a27..2a42ccf 100644 --- a/internal/engine/awslambda/binary.go +++ b/internal/engine/awslambda/binary.go @@ -1,10 +1,13 @@ package awslambda import ( + "archive/zip" "fmt" + "io" "os" "path/filepath" + "github.com/imposter-project/imposter-cli/internal/compression" "github.com/imposter-project/imposter-cli/internal/engine" library2 "github.com/imposter-project/imposter-cli/internal/library" "github.com/spf13/viper" @@ -16,40 +19,39 @@ import ( // produce a deployable artefact. const DefaultLambdaArch = "amd64" -// lambdaBinarySpec describes the source and local naming for an AWS Lambda -// deployment binary. The JVM-engine flavour ships a single arch-agnostic zip -// from imposter-jvm-engine; the native (imposter-go) flavour ships separate -// per-architecture zips from imposter-go. +// lambdaBinarySpec materialises a Lambda-ready zip into the local cache for +// a given engine flavour. cacheFile names the on-disk artefact; assemble +// fetches and (where necessary) converts the upstream release into that zip. type lambdaBinarySpec struct { - downloadConfig library2.DownloadConfig - remoteFile func(arch string) string - cacheFile func(arch, version string) string + cacheFile func(arch, version string) string + assemble func(cachePath, version, arch string) error } +// jvmLambdaSpec ships a pre-built Lambda zip from imposter-jvm-engine that +// is already arch-agnostic and Lambda-ready, so assembly is a direct +// download. var jvmLambdaSpec = lambdaBinarySpec{ - downloadConfig: library2.NewDownloadConfig( - "https://github.com/imposter-project/imposter-jvm-engine/releases/latest/download", - "https://github.com/imposter-project/imposter-jvm-engine/releases/download/v%v", - false, - ), - remoteFile: func(string) string { return "imposter-awslambda.zip" }, cacheFile: func(_, version string) string { return fmt.Sprintf("imposter-awslambda-%s.zip", version) }, + assemble: func(cachePath, version, _ string) error { + dc := library2.NewDownloadConfig( + "https://github.com/imposter-project/imposter-jvm-engine/releases/latest/download", + "https://github.com/imposter-project/imposter-jvm-engine/releases/download/v%v", + false, + ) + return library2.DownloadBinary(dc, cachePath, "imposter-awslambda.zip", version) + }, } +// nativeLambdaSpec consumes the per-arch imposter-go release tarball and +// repackages the contained binary as a Lambda custom-runtime zip whose +// single entry is the executable "bootstrap". var nativeLambdaSpec = lambdaBinarySpec{ - downloadConfig: library2.NewDownloadConfig( - "https://github.com/imposter-project/imposter-go/releases/latest/download", - "https://github.com/imposter-project/imposter-go/releases/download/v%v", - false, - ), - remoteFile: func(arch string) string { - return fmt.Sprintf("imposter-awslambda_%s.zip", arch) - }, cacheFile: func(arch, version string) string { return fmt.Sprintf("imposter-go-awslambda-%s-%s.zip", arch, version) }, + assemble: assembleNativeLambdaZip, } // specForVersion picks the AWS Lambda binary spec for the given engine @@ -88,7 +90,7 @@ func checkOrDownloadBinary(version string, arch string) (string, error) { return binFilePath, nil } - if err := library2.DownloadBinary(spec.downloadConfig, binFilePath, spec.remoteFile(arch), version); err != nil { + if err := spec.assemble(binFilePath, version, arch); err != nil { return "", fmt.Errorf("failed to fetch lambda binary: %v", err) } } @@ -96,6 +98,89 @@ func checkOrDownloadBinary(version string, arch string) (string, error) { return binFilePath, nil } +// assembleNativeLambdaZip downloads the imposter-go linux release tarball +// for the requested architecture, extracts the imposter-go binary, and +// writes a Lambda-ready zip to cachePath containing a single executable +// "bootstrap" entry (as required by the provided.al2023 custom runtime). +func assembleNativeLambdaZip(cachePath, version, arch string) error { + dc := library2.NewDownloadConfig( + "https://github.com/imposter-project/imposter-go/releases/latest/download", + "https://github.com/imposter-project/imposter-go/releases/download/v%v", + false, + ) + remoteFile := fmt.Sprintf("imposter-go_linux_%s.tar.gz", arch) + + tempDir, err := os.MkdirTemp("", "imposter-go-lambda-") + if err != nil { + return fmt.Errorf("failed to create temp dir: %v", err) + } + defer os.RemoveAll(tempDir) + + tarballPath := filepath.Join(tempDir, remoteFile) + if err := library2.DownloadBinary(dc, tarballPath, remoteFile, version); err != nil { + return err + } + + binaryPath, err := extractGoBinary(tarballPath, tempDir) + if err != nil { + return fmt.Errorf("failed to extract imposter-go binary from %s: %v", tarballPath, err) + } + + if err := writeBootstrapZip(binaryPath, cachePath); err != nil { + return fmt.Errorf("failed to write lambda zip %s: %v", cachePath, err) + } + return nil +} + +// extractGoBinary extracts the imposter-go release tarball and returns the +// path to the extracted "imposter-go" binary. The release archive currently +// also contains README/CHANGELOG files which are ignored. +func extractGoBinary(tarballPath, destDir string) (string, error) { + if err := compression.ExtractTarGz(tarballPath, destDir); err != nil { + return "", err + } + binaryPath := filepath.Join(destDir, "imposter-go") + if _, err := os.Stat(binaryPath); err != nil { + return "", fmt.Errorf("imposter-go binary not found in archive: %v", err) + } + return binaryPath, nil +} + +// writeBootstrapZip writes a zip containing a single "bootstrap" entry with +// the contents of binaryPath. The entry is marked executable so AWS +// Lambda's provided.al2023 runtime will run it. +func writeBootstrapZip(binaryPath, zipPath string) error { + src, err := os.Open(binaryPath) + if err != nil { + return err + } + defer src.Close() + + out, err := os.Create(zipPath) + if err != nil { + return err + } + defer out.Close() + + zw := zip.NewWriter(out) + defer zw.Close() + + header := &zip.FileHeader{ + Name: "bootstrap", + Method: zip.Deflate, + } + header.SetMode(0755) + + w, err := zw.CreateHeader(header) + if err != nil { + return err + } + if _, err := io.Copy(w, src); err != nil { + return err + } + return nil +} + func ensureBinCache() (string, error) { homeDir, err := os.UserHomeDir() if err != nil {