From 06a065fa9041e096b039477850093d026a2b67cd Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Thu, 15 Oct 2026 19:54:01 +0530 Subject: [PATCH 1/7] fix(aws-lambda): close the real-cloud parity gaps in #1518 - A published version now freezes its CodeSize, description, environment and AWS-only settings (VpcConfig, DeadLetterConfig, TracingConfig, Architectures, EphemeralStorage, Layers) at publish time, so UpdateFunctionCode/Configuration on $LATEST no longer changes what GetFunction --qualifier N or ListVersionsByFunction report for it. - Under AsyncSettle a new function reports State Pending (StateReasonCode Creating, no LastUpdateStatus) and an update reports LastUpdateStatus InProgress. While Pending, Invoke, UpdateFunctionCode, UpdateFunctionConfiguration and PublishVersion return ResourceConflictException; while InProgress the updates, PublishVersion and TagResource do. The InProgress gate also covers an in-flight engine deploy. Publish=true on Create/UpdateFunctionCode is not blocked by the window its own write opened. Settle off keeps the old behaviour. - Invoke with LogType=Tail returns X-Amz-Log-Result: the base64 of the last 4 KB of the execution log the provider already renders. - GetAccountSettings (GET /2016-08-19/account-settings). - Code signing configs: Create/Get/List/Update/DeleteCodeSigningConfig, Put/Get/DeleteFunctionCodeSigningConfig, ListFunctionsByCodeSigningConfig, CodeSigningConfigArn on CreateFunction, and tagging a code signing config ARN, with IAM checks per the Service Authorization Reference. State is in the Lambda snapshot. - ResourceNotFoundException for a missing function, version or alias reads "Function not found: [:]". Part of #1518 --- internal/settle/settle.go | 3 + providers/aws/lambda/account_settings.go | 58 +++ providers/aws/lambda/aliases.go | 20 +- providers/aws/lambda/awsconfig.go | 28 +- providers/aws/lambda/code_signing.go | 313 +++++++++++++ providers/aws/lambda/concurrency.go | 12 +- providers/aws/lambda/engine_ops.go | 25 +- providers/aws/lambda/event_invoke_config.go | 24 +- providers/aws/lambda/execlogs.go | 45 +- providers/aws/lambda/functionurl.go | 20 +- providers/aws/lambda/invoke_sync.go | 3 +- providers/aws/lambda/lambda.go | 93 +++- providers/aws/lambda/lifecycle.go | 141 ++++++ providers/aws/lambda/parity_1518_test.go | 431 ++++++++++++++++++ providers/aws/lambda/policy.go | 20 +- .../aws/lambda/provisioned_concurrency.go | 18 +- providers/aws/lambda/snapshot.go | 42 +- providers/aws/lambda/tags.go | 19 +- providers/aws/lambda/versions.go | 128 ++++-- server/aws/authz_matrix_lambda_test.go | 47 +- server/aws/lambda/account_settings.go | 62 +++ server/aws/lambda/classify.go | 48 +- server/aws/lambda/classify_test.go | 9 +- server/aws/lambda/code_signing.go | 379 ++++++++++++++- server/aws/lambda/handler.go | 127 +++++- server/aws/lambda/iam_actions.go | 70 ++- server/aws/lambda/iam_actions_test.go | 12 + server/aws/lambda/parity_1518_sdk_test.go | 365 +++++++++++++++ server/aws/lambda/types.go | 6 + services/serverless/driver/driver.go | 64 +++ 30 files changed, 2450 insertions(+), 182 deletions(-) create mode 100644 providers/aws/lambda/account_settings.go create mode 100644 providers/aws/lambda/code_signing.go create mode 100644 providers/aws/lambda/lifecycle.go create mode 100644 providers/aws/lambda/parity_1518_test.go create mode 100644 server/aws/lambda/account_settings.go create mode 100644 server/aws/lambda/parity_1518_sdk_test.go diff --git a/internal/settle/settle.go b/internal/settle/settle.go index fa40c8347..015022d66 100644 --- a/internal/settle/settle.go +++ b/internal/settle/settle.go @@ -95,6 +95,9 @@ const ( // longer than the other phases so a real CLI or SDK client can observe a // running job and call TerminateJob on it before it finishes. DefaultBatchJobRunDwell = 5 * time.Second + + DefaultLambdaCreateSettle = 1 * time.Second // Lambda function State Pending->Active + DefaultLambdaUpdateSettle = 1 * time.Second // Lambda LastUpdateStatus InProgress->Successful ) // Window is a read-time overlay describing a resource still settling into its diff --git a/providers/aws/lambda/account_settings.go b/providers/aws/lambda/account_settings.go new file mode 100644 index 000000000..678b5823c --- /dev/null +++ b/providers/aws/lambda/account_settings.go @@ -0,0 +1,58 @@ +package lambda + +import ( + "context" + + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// AWS Lambda default account limits, in bytes, from the Lambda quotas page +// (https://docs.aws.amazon.com/lambda/latest/dg/gettingstarted-limits.html): +// 300 GB of code storage for .zip functions and layers, a 50 MB zipped +// deployment package uploaded directly, and 250 MB unzipped including layers. +// The concurrency budget is accountConcurrentExecutionLimit. +const ( + accountTotalCodeSizeLimit int64 = 300 * 1024 * 1024 * 1024 + accountCodeSizeZipped int64 = 50 * 1024 * 1024 + accountCodeSizeUnzipped int64 = 250 * 1024 * 1024 +) + +// GetAccountSettings reports the account's Lambda limits and current usage +// (GetAccountSettings). UnreservedConcurrentExecutions is the concurrency pool +// minus every function's reserved concurrency; FunctionCount counts functions, +// and the used TotalCodeSize sums the deployment packages of every function's +// $LATEST and published versions plus every layer version, since "each +// function version and layer version consumes storage". It is an AWS-only +// capability the Lambda server handler type-asserts for. +func (m *Mock) GetAccountSettings(_ context.Context) (*driver.AccountSettings, error) { + var ( + functions int64 + codeSize int64 + ) + + all := m.funcs.All() + for name := range all { + functions++ + codeSize += all[name].info.CodeSize + + for _, v := range all[name].versions { + codeSize += v.codeSize + } + } + + for _, ld := range m.layers.All() { + for _, lv := range ld.versions.All() { + codeSize += lv.ContentSize + } + } + + return &driver.AccountSettings{ + TotalCodeSizeLimit: accountTotalCodeSizeLimit, + CodeSizeUnzipped: accountCodeSizeUnzipped, + CodeSizeZipped: accountCodeSizeZipped, + ConcurrentExecutions: accountConcurrentExecutionLimit, + UnreservedConcurrentExecutions: accountConcurrentExecutionLimit - m.sumReservedConcurrencyExcept(""), + FunctionCount: functions, + TotalCodeSizeUsed: codeSize, + }, nil +} diff --git a/providers/aws/lambda/aliases.go b/providers/aws/lambda/aliases.go index 01555c05d..db67d14e4 100644 --- a/providers/aws/lambda/aliases.go +++ b/providers/aws/lambda/aliases.go @@ -11,13 +11,13 @@ import ( ) // CreateAlias creates a new alias pointing to a specific function version. -func (m *Mock) CreateAlias(_ context.Context, cfg driver.AliasConfig) (*driver.Alias, error) { +func (m *Mock) CreateAlias(ctx context.Context, cfg driver.AliasConfig) (*driver.Alias, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(cfg.FunctionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", cfg.FunctionName) + return nil, m.errFunctionNotFound(ctx, cfg.FunctionName, "") } if fd.aliases.Has(cfg.Name) { @@ -56,13 +56,13 @@ func (m *Mock) CreateAlias(_ context.Context, cfg driver.AliasConfig) (*driver.A } // UpdateAlias updates an existing alias configuration. -func (m *Mock) UpdateAlias(_ context.Context, cfg driver.AliasConfig) (*driver.Alias, error) { +func (m *Mock) UpdateAlias(ctx context.Context, cfg driver.AliasConfig) (*driver.Alias, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(cfg.FunctionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", cfg.FunctionName) + return nil, m.errFunctionNotFound(ctx, cfg.FunctionName, "") } ad, ok := fd.aliases.Get(cfg.Name) @@ -121,13 +121,13 @@ func (m *Mock) UpdateAlias(_ context.Context, cfg driver.AliasConfig) (*driver.A // provisioned concurrency config and event invoke config. Real Lambda keeps // these as sub-resources of the alias ARN, so a later alias with the same name // starts with none of them instead of inheriting the old grants. -func (m *Mock) DeleteAlias(_ context.Context, functionName, aliasName string) error { +func (m *Mock) DeleteAlias(ctx context.Context, functionName, aliasName string) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return m.errFunctionNotFound(ctx, functionName, "") } if !fd.aliases.Has(aliasName) { @@ -166,10 +166,10 @@ func withoutKey[V any](src map[string]V, key string) map[string]V { } // GetAlias retrieves a specific alias for a function. -func (m *Mock) GetAlias(_ context.Context, functionName, aliasName string) (*driver.Alias, error) { +func (m *Mock) GetAlias(ctx context.Context, functionName, aliasName string) (*driver.Alias, error) { fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } ad, ok := fd.aliases.Get(aliasName) @@ -185,10 +185,10 @@ func (m *Mock) GetAlias(_ context.Context, functionName, aliasName string) (*dri } // ListAliases returns all aliases for a function. -func (m *Mock) ListAliases(_ context.Context, functionName string) ([]driver.Alias, error) { +func (m *Mock) ListAliases(ctx context.Context, functionName string) ([]driver.Alias, error) { fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } all := fd.aliases.All() diff --git a/providers/aws/lambda/awsconfig.go b/providers/aws/lambda/awsconfig.go index 74a397e42..6992ae5cf 100644 --- a/providers/aws/lambda/awsconfig.go +++ b/providers/aws/lambda/awsconfig.go @@ -3,7 +3,6 @@ package lambda import ( "context" - cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/services/serverless/driver" ) @@ -16,13 +15,13 @@ import ( // surface. // //nolint:gocritic // hugeParam: interface method signature cannot be changed. -func (m *Mock) SetFunctionAWSConfig(_ context.Context, name string, cfg driver.AWSFunctionConfig, create bool) error { +func (m *Mock) SetFunctionAWSConfig(ctx context.Context, name string, cfg driver.AWSFunctionConfig, create bool) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(name) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return m.errFunctionNotFound(ctx, name, "") } if create { @@ -78,18 +77,23 @@ func overlayAWSConfig(dst *driver.AWSFunctionConfig, cfg driver.AWSFunctionConfi // GetFunctionAWSConfig returns a copy of the stored AWS-only settings for a // function. -func (m *Mock) GetFunctionAWSConfig(_ context.Context, name string) (driver.AWSFunctionConfig, error) { +func (m *Mock) GetFunctionAWSConfig(ctx context.Context, name string) (driver.AWSFunctionConfig, error) { fd, ok := m.funcs.Get(name) if !ok { - return driver.AWSFunctionConfig{}, cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return driver.AWSFunctionConfig{}, m.errFunctionNotFound(ctx, name, "") } + return cloneAWSConfig(&fd.awsConfig), nil +} + +// cloneAWSConfig deep-copies a function's AWS-only settings. +func cloneAWSConfig(c *driver.AWSFunctionConfig) driver.AWSFunctionConfig { return driver.AWSFunctionConfig{ - VPCConfig: cloneVPCConfig(fd.awsConfig.VPCConfig), - DeadLetterConfig: cloneDeadLetterConfig(fd.awsConfig.DeadLetterConfig), - TracingConfig: cloneTracingConfig(fd.awsConfig.TracingConfig), - Architectures: cloneArchitectures(fd.awsConfig.Architectures), - EphemeralStorage: cloneEphemeralStorage(fd.awsConfig.EphemeralStorage), - Layers: cloneLayers(fd.awsConfig.Layers), - }, nil + VPCConfig: cloneVPCConfig(c.VPCConfig), + DeadLetterConfig: cloneDeadLetterConfig(c.DeadLetterConfig), + TracingConfig: cloneTracingConfig(c.TracingConfig), + Architectures: cloneArchitectures(c.Architectures), + EphemeralStorage: cloneEphemeralStorage(c.EphemeralStorage), + Layers: cloneLayers(c.Layers), + } } diff --git a/providers/aws/lambda/code_signing.go b/providers/aws/lambda/code_signing.go new file mode 100644 index 000000000..12cc2656f --- /dev/null +++ b/providers/aws/lambda/code_signing.go @@ -0,0 +1,313 @@ +package lambda + +import ( + "context" + "maps" + "sort" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/regionctx" + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// Code signing config constraints from the Lambda API model +// (CreateCodeSigningConfig / AllowedPublishers / CodeSigningPolicies). See +// https://docs.aws.amazon.com/lambda/latest/api/API_CreateCodeSigningConfig.html +const ( + codeSigningPolicyWarn = "Warn" + codeSigningPolicyEnforce = "Enforce" + + minSigningProfiles = 1 + maxSigningProfiles = 20 + maxCodeSigningDescLen = 256 + codeSigningConfigPrefix = "csc-" + // codeSigningTimeFormat is the ISO-8601 LastModified shape Lambda uses + // (YYYY-MM-DDThh:mm:ss.sTZD). + codeSigningTimeFormat = "2006-01-02T15:04:05.000-0700" +) + +// The Mock methods below are the AWS-only code signing surface the Lambda server +// handler type-asserts for: code signing configs have no Azure Functions or GCP +// Cloud Functions equivalent. cloudemu stores and associates the configs but +// does not verify deployment-package signatures. + +// CreateCodeSigningConfig creates a code signing config. UntrustedArtifactOnDeployment +// defaults to "Warn", as in AWS. +// +//nolint:gocritic // hugeParam: value mirrors the create request. +func (m *Mock) CreateCodeSigningConfig(ctx context.Context, cfg driver.CodeSigningConfig) (*driver.CodeSigningConfig, error) { + if cfg.UntrustedArtifactOnDeployment == "" { + cfg.UntrustedArtifactOnDeployment = codeSigningPolicyWarn + } + + if err := validateCodeSigningConfig(cfg.Description, cfg.SigningProfileVersionARNs, + cfg.UntrustedArtifactOnDeployment); err != nil { + return nil, err + } + + id, err := idgen.GenerateLongID(codeSigningConfigPrefix) + if err != nil { + return nil, cerrors.Newf(cerrors.Internal, "generate code signing config id: %v", err) + } + + out := driver.CodeSigningConfig{ + ID: id, + ARN: idgen.AWSARN("lambda", regionctx.RegionOr(ctx, m.opts.Region), m.opts.AccountID, + "code-signing-config:"+id), + Description: cfg.Description, + SigningProfileVersionARNs: append([]string(nil), cfg.SigningProfileVersionARNs...), + UntrustedArtifactOnDeployment: cfg.UntrustedArtifactOnDeployment, + LastModified: m.opts.Clock.Now().UTC().Format(codeSigningTimeFormat), + Tags: maps.Clone(cfg.Tags), + } + + m.cscs.Set(out.ARN, out) + + return cloneCodeSigningConfig(&out), nil +} + +// GetCodeSigningConfig returns the code signing config with the given ARN. +func (m *Mock) GetCodeSigningConfig(_ context.Context, arn string) (*driver.CodeSigningConfig, error) { + c, ok := m.cscs.Get(arn) + if !ok { + return nil, errCodeSigningConfigNotFound(arn) + } + + return cloneCodeSigningConfig(&c), nil +} + +// ListCodeSigningConfigs returns every code signing config, sorted by ARN so +// paginated listings stay stable. +func (m *Mock) ListCodeSigningConfigs(_ context.Context) ([]driver.CodeSigningConfig, error) { + all := m.cscs.All() + out := make([]driver.CodeSigningConfig, 0, len(all)) + + for _, c := range all { + out = append(out, *cloneCodeSigningConfig(&c)) + } + + sort.Slice(out, func(i, j int) bool { return out[i].ARN < out[j].ARN }) + + return out, nil +} + +// UpdateCodeSigningConfig applies the non-nil fields of upd to a code signing config. +func (m *Mock) UpdateCodeSigningConfig( + _ context.Context, arn string, upd driver.CodeSigningConfigUpdate, +) (*driver.CodeSigningConfig, error) { + m.mu.Lock() + defer m.mu.Unlock() + + c, ok := m.cscs.Get(arn) + if !ok { + return nil, errCodeSigningConfigNotFound(arn) + } + + if upd.Description != nil { + c.Description = *upd.Description + } + + if upd.SigningProfileVersionARNs != nil { + c.SigningProfileVersionARNs = append([]string(nil), upd.SigningProfileVersionARNs...) + } + + if upd.UntrustedArtifactOnDeployment != nil { + c.UntrustedArtifactOnDeployment = *upd.UntrustedArtifactOnDeployment + } + + if err := validateCodeSigningConfig(c.Description, c.SigningProfileVersionARNs, + c.UntrustedArtifactOnDeployment); err != nil { + return nil, err + } + + c.LastModified = m.opts.Clock.Now().UTC().Format(codeSigningTimeFormat) + m.cscs.Set(arn, c) + + return cloneCodeSigningConfig(&c), nil +} + +// DeleteCodeSigningConfig deletes a code signing config. AWS allows it "only if +// no function is using it", so a config still attached to a function is a +// ResourceConflictException. +func (m *Mock) DeleteCodeSigningConfig(_ context.Context, arn string) error { + m.mu.Lock() + defer m.mu.Unlock() + + if _, ok := m.cscs.Get(arn); !ok { + return errCodeSigningConfigNotFound(arn) + } + + if len(m.functionsUsingCodeSigningConfig(arn)) > 0 { + return cerrors.Newf(cerrors.AlreadyExists, + "Code signing configuration %s is used by one or more functions and cannot be deleted.", arn) + } + + m.cscs.Delete(arn) + + return nil +} + +// PutFunctionCodeSigningConfig attaches a code signing config to a function, +// replacing any config already attached. +func (m *Mock) PutFunctionCodeSigningConfig(ctx context.Context, name, arn string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(name) + if !ok { + return m.errFunctionNotFound(ctx, name, "") + } + + if _, ok := m.cscs.Get(arn); !ok { + return errCodeSigningConfigNotFound(arn) + } + + fd.codeSigningConfigARN = arn + m.funcs.Set(name, fd) + + return nil +} + +// GetFunctionCodeSigningConfig returns the ARN of the code signing config +// attached to a function, or "" when none is. +func (m *Mock) GetFunctionCodeSigningConfig(ctx context.Context, name string) (string, error) { + fd, ok := m.funcs.Get(name) + if !ok { + return "", m.errFunctionNotFound(ctx, name, "") + } + + return fd.codeSigningConfigARN, nil +} + +// DeleteFunctionCodeSigningConfig detaches a function's code signing config. A +// function with none attached is left unchanged. +func (m *Mock) DeleteFunctionCodeSigningConfig(ctx context.Context, name string) error { + m.mu.Lock() + defer m.mu.Unlock() + + fd, ok := m.funcs.Get(name) + if !ok { + return m.errFunctionNotFound(ctx, name, "") + } + + fd.codeSigningConfigARN = "" + m.funcs.Set(name, fd) + + return nil +} + +// ListFunctionsByCodeSigningConfig returns the ARNs of the functions a code +// signing config is attached to, sorted. +func (m *Mock) ListFunctionsByCodeSigningConfig(_ context.Context, arn string) ([]string, error) { + if _, ok := m.cscs.Get(arn); !ok { + return nil, errCodeSigningConfigNotFound(arn) + } + + return m.functionsUsingCodeSigningConfig(arn), nil +} + +// functionsUsingCodeSigningConfig returns the sorted ARNs of the functions arn +// is attached to. +func (m *Mock) functionsUsingCodeSigningConfig(arn string) []string { + var out []string + + all := m.funcs.All() + for name := range all { + if all[name].codeSigningConfigARN == arn { + out = append(out, all[name].info.ARN) + } + } + + sort.Strings(out) + + return out +} + +// validateCodeSigningConfig enforces the API model constraints: 1-20 signing +// profile version ARNs, a policy of Warn or Enforce, and a description of at +// most 256 characters. Violations are InvalidParameterValueException. +func validateCodeSigningConfig(description string, profiles []string, policy string) error { + if len(profiles) < minSigningProfiles || len(profiles) > maxSigningProfiles { + return cerrors.Newf(cerrors.InvalidArgument, + "AllowedPublishers.SigningProfileVersionArns must contain between %d and %d ARNs", + minSigningProfiles, maxSigningProfiles) + } + + if policy != codeSigningPolicyWarn && policy != codeSigningPolicyEnforce { + return cerrors.Newf(cerrors.InvalidArgument, + "value '%s' at 'codeSigningPolicies.untrustedArtifactOnDeployment' failed to satisfy constraint: "+ + "Member must satisfy enum value set: [%s, %s]", policy, codeSigningPolicyWarn, codeSigningPolicyEnforce) + } + + if len(description) > maxCodeSigningDescLen { + return cerrors.Newf(cerrors.InvalidArgument, + "value at 'description' failed to satisfy constraint: Member must have length less than or equal to %d", + maxCodeSigningDescLen) + } + + return nil +} + +func errCodeSigningConfigNotFound(arn string) error { + return cerrors.Newf(cerrors.NotFound, "Code signing configuration not found: %s", arn) +} + +func cloneCodeSigningConfig(c *driver.CodeSigningConfig) *driver.CodeSigningConfig { + out := *c + out.SigningProfileVersionARNs = append([]string(nil), c.SigningProfileVersionARNs...) + out.Tags = maps.Clone(c.Tags) + + return &out +} + +// TagCodeSigningConfig adds or overwrites tags on a code signing config +// (TagResource on a code signing config ARN). +func (m *Mock) TagCodeSigningConfig(_ context.Context, arn string, tags map[string]string) error { + m.mu.Lock() + defer m.mu.Unlock() + + c, ok := m.cscs.Get(arn) + if !ok { + return errCodeSigningConfigNotFound(arn) + } + + next := make(map[string]string, len(c.Tags)+len(tags)) + maps.Copy(next, c.Tags) + maps.Copy(next, tags) + c.Tags = next + m.cscs.Set(arn, c) + + return nil +} + +// UntagCodeSigningConfig removes tags by key from a code signing config. +func (m *Mock) UntagCodeSigningConfig(_ context.Context, arn string, keys []string) error { + m.mu.Lock() + defer m.mu.Unlock() + + c, ok := m.cscs.Get(arn) + if !ok { + return errCodeSigningConfigNotFound(arn) + } + + next := maps.Clone(c.Tags) + for _, k := range keys { + delete(next, k) + } + + c.Tags = next + m.cscs.Set(arn, c) + + return nil +} + +// ListCodeSigningConfigTags returns a code signing config's tags. +func (m *Mock) ListCodeSigningConfigTags(_ context.Context, arn string) (map[string]string, error) { + c, ok := m.cscs.Get(arn) + if !ok { + return nil, errCodeSigningConfigNotFound(arn) + } + + return maps.Clone(c.Tags), nil +} diff --git a/providers/aws/lambda/concurrency.go b/providers/aws/lambda/concurrency.go index 0fce1e5bc..7a16f189c 100644 --- a/providers/aws/lambda/concurrency.go +++ b/providers/aws/lambda/concurrency.go @@ -24,13 +24,13 @@ const ( // provisioned concurrency is carved out of the reserved budget. It also blocks // a reservation that would push the account's shared unreserved-concurrency // pool below its 100-execution minimum, exactly as real Lambda does. -func (m *Mock) PutFunctionConcurrency(_ context.Context, cfg driver.ConcurrencyConfig) error { +func (m *Mock) PutFunctionConcurrency(ctx context.Context, cfg driver.ConcurrencyConfig) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(cfg.FunctionName) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", cfg.FunctionName) + return m.errFunctionNotFound(ctx, cfg.FunctionName, "") } if provisioned := sumProvisionedConcurrency(&fd); cfg.ReservedConcurrentExecutions < provisioned { @@ -81,10 +81,10 @@ func (m *Mock) sumReservedConcurrencyExcept(excludeName string) int { } // GetFunctionConcurrency retrieves the concurrency configuration for a function. -func (m *Mock) GetFunctionConcurrency(_ context.Context, functionName string) (*driver.ConcurrencyConfig, error) { +func (m *Mock) GetFunctionConcurrency(ctx context.Context, functionName string) (*driver.ConcurrencyConfig, error) { fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } if fd.concurrency == nil { @@ -100,13 +100,13 @@ func (m *Mock) GetFunctionConcurrency(_ context.Context, functionName string) (* // function. Real Lambda blocks removing reserved concurrency entirely while // any provisioned concurrency is still configured on the function (the same // invariant PutFunctionConcurrency enforces for lowering it). -func (m *Mock) DeleteFunctionConcurrency(_ context.Context, functionName string) error { +func (m *Mock) DeleteFunctionConcurrency(ctx context.Context, functionName string) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return m.errFunctionNotFound(ctx, functionName, "") } if provisioned := sumProvisionedConcurrency(&fd); provisioned > 0 { diff --git a/providers/aws/lambda/engine_ops.go b/providers/aws/lambda/engine_ops.go index 7501e35fd..3af23264c 100644 --- a/providers/aws/lambda/engine_ops.go +++ b/providers/aws/lambda/engine_ops.go @@ -90,6 +90,7 @@ func (m *Mock) deployNewFunction(ctx context.Context, cfg *driver.FunctionConfig _, appeared := m.funcs.Get(cfg.Name) if !appeared { m.funcs.Set(cfg.Name, m.newFuncData(info, backed)) + m.beginCreateSettle(cfg.Name) } m.mu.Unlock() @@ -103,6 +104,7 @@ func (m *Mock) deployNewFunction(ctx context.Context, cfg *driver.FunctionConfig } result := info + m.observe(&result) return &result, nil } @@ -118,10 +120,11 @@ func (m *Mock) DeleteFunction(ctx context.Context, name string) error { fd, ok := m.funcs.Get(name) if !ok { m.mu.Unlock() - return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return m.errFunctionNotFound(ctx, name, "") } m.funcs.Delete(name) + m.clearSettle(name) if m.engineBusy(name) || !fd.engineBacked { m.mu.Unlock() @@ -146,12 +149,12 @@ func (m *Mock) UpdateFunction(ctx context.Context, name string, cfg driver.Funct m.mu.Lock() defer m.mu.Unlock() - return m.commitUpdate(name, cfg, nil) + return m.commitUpdate(ctx, name, cfg, nil) } // A code update re-deploys to the engine using the post-merge runtime and // handler, so the function runs the new code, not the stale deployment. - info, token, err := m.reserveCodeUpdate(name, cfg) + info, token, err := m.reserveCodeUpdate(ctx, name, cfg) if err != nil { return nil, err } @@ -171,7 +174,7 @@ func (m *Mock) UpdateFunction(ctx context.Context, name string, cfg driver.Funct // to this call. m.removeDeployment(ctx, name) - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return nil, m.errFunctionNotFound(ctx, name, "") } defer m.mu.Unlock() @@ -180,7 +183,7 @@ func (m *Mock) UpdateFunction(ctx context.Context, name string, cfg driver.Funct return nil, cerrors.Newf(cerrors.InvalidArgument, "deploy function %s: %v", name, err) } - return m.commitUpdate(name, cfg, &backed) + return m.commitUpdate(ctx, name, cfg, &backed) } // reserveCodeUpdate validates a code update against the current function and @@ -188,17 +191,21 @@ func (m *Mock) UpdateFunction(ctx context.Context, name string, cfg driver.Funct // configuration to deploy and the reservation token. // //nolint:gocritic // hugeParam: cfg is the by-value driver config. -func (m *Mock) reserveCodeUpdate(name string, cfg driver.FunctionConfig) (driver.FunctionInfo, uint64, error) { +func (m *Mock) reserveCodeUpdate( + ctx context.Context, name string, cfg driver.FunctionConfig, +) (driver.FunctionInfo, uint64, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(name) if !ok { - return driver.FunctionInfo{}, 0, cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return driver.FunctionInfo{}, 0, m.errFunctionNotFound(ctx, name, "") } - if m.engineBusy(name) { - return driver.FunctionInfo{}, 0, updateInProgress(fd.info.ARN) + // Refused while Pending creation or while an update (an AsyncSettle window + // or an engine deploy) is InProgress. + if err := m.checkUpdatable(&fd); err != nil { + return driver.FunctionInfo{}, 0, err } info, err := m.mergeUpdate(fd.info, cfg) diff --git a/providers/aws/lambda/event_invoke_config.go b/providers/aws/lambda/event_invoke_config.go index 33dcadb5e..1ff2ee233 100644 --- a/providers/aws/lambda/event_invoke_config.go +++ b/providers/aws/lambda/event_invoke_config.go @@ -25,7 +25,7 @@ const ( // //nolint:gocritic // hugeParam: interface method signature cannot be changed. func (m *Mock) PutFunctionEventInvokeConfig( - _ context.Context, cfg driver.EventInvokeConfig, + ctx context.Context, cfg driver.EventInvokeConfig, ) (*driver.EventInvokeConfig, error) { if err := validateEventInvokeConfig(cfg); err != nil { return nil, err @@ -36,10 +36,10 @@ func (m *Mock) PutFunctionEventInvokeConfig( fd, ok := m.funcs.Get(cfg.FunctionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", cfg.FunctionName) + return nil, m.errFunctionNotFound(ctx, cfg.FunctionName, "") } - if _, err := m.resolveQualifier(&fd, cfg.Qualifier, nil); err != nil { + if _, err := m.resolveQualifier(ctx, &fd, cfg.Qualifier, nil); err != nil { return nil, err } @@ -68,7 +68,7 @@ func (m *Mock) PutFunctionEventInvokeConfig( // //nolint:gocritic // hugeParam: interface method signature cannot be changed. func (m *Mock) UpdateFunctionEventInvokeConfig( - _ context.Context, cfg driver.EventInvokeConfig, + ctx context.Context, cfg driver.EventInvokeConfig, ) (*driver.EventInvokeConfig, error) { if err := validateEventInvokeConfig(cfg); err != nil { return nil, err @@ -79,10 +79,10 @@ func (m *Mock) UpdateFunctionEventInvokeConfig( fd, ok := m.funcs.Get(cfg.FunctionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", cfg.FunctionName) + return nil, m.errFunctionNotFound(ctx, cfg.FunctionName, "") } - if _, err := m.resolveQualifier(&fd, cfg.Qualifier, nil); err != nil { + if _, err := m.resolveQualifier(ctx, &fd, cfg.Qualifier, nil); err != nil { return nil, err } @@ -116,14 +116,14 @@ func (m *Mock) UpdateFunctionEventInvokeConfig( // GetFunctionEventInvokeConfig returns the async-invoke config for a function // version/alias, or NotFound (ResourceNotFoundException) when none is set. func (m *Mock) GetFunctionEventInvokeConfig( - _ context.Context, functionName, qualifier string, + ctx context.Context, functionName, qualifier string, ) (*driver.EventInvokeConfig, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } cfg, ok := fd.eventInvokeConfigs[policyKey(qualifier)] @@ -139,13 +139,13 @@ func (m *Mock) GetFunctionEventInvokeConfig( // DeleteFunctionEventInvokeConfig removes the async-invoke config for a function // version/alias, reverting it to the default (2 retries, no destinations). -func (m *Mock) DeleteFunctionEventInvokeConfig(_ context.Context, functionName, qualifier string) error { +func (m *Mock) DeleteFunctionEventInvokeConfig(ctx context.Context, functionName, qualifier string) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return m.errFunctionNotFound(ctx, functionName, "") } key := policyKey(qualifier) @@ -170,14 +170,14 @@ func (m *Mock) DeleteFunctionEventInvokeConfig(_ context.Context, functionName, // ListFunctionEventInvokeConfigs returns every async-invoke config set on a // function (one per qualifier), backing AWS ListFunctionEventInvokeConfigs. func (m *Mock) ListFunctionEventInvokeConfigs( - _ context.Context, functionName string, + ctx context.Context, functionName string, ) ([]driver.EventInvokeConfig, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } out := make([]driver.EventInvokeConfig, 0, len(fd.eventInvokeConfigs)) diff --git a/providers/aws/lambda/execlogs.go b/providers/aws/lambda/execlogs.go index 32b13fa0d..e08119f1e 100644 --- a/providers/aws/lambda/execlogs.go +++ b/providers/aws/lambda/execlogs.go @@ -14,22 +14,29 @@ import ( // function's invocation logs under: /aws/lambda/. const logGroupPrefix = "/aws/lambda/" -// surfaceInvokeLogs writes one invocation's log lines into CloudWatch Logs, -// mirroring the START/END/REPORT bookend real Lambda emits, plus any captured -// stdout/stderr (from the real-engine path). It is best-effort: with no log -// sink wired it is a no-op, so library users are unaffected. -func (m *Mock) surfaceInvokeLogs(ctx context.Context, functionName, executedVersion, captured, funcError string) { +// logTailBytes is how much of the execution log Invoke returns for +// LogType=Tail: "The last 4 KB of the execution log, which is base64-encoded." +// See https://docs.aws.amazon.com/lambda/latest/api/API_Invoke.html +const logTailBytes = 4 * 1024 + +// surfaceInvokeLogs renders one invocation's execution log (the START/END/ +// REPORT bookend real Lambda emits, plus any captured stdout/stderr from the +// real-engine path), writes it into CloudWatch Logs when a log sink is wired +// (best-effort: with no sink, library users are unaffected), and returns its +// tail, the last 4 KB that Invoke reports for LogType=Tail. +func (m *Mock) surfaceInvokeLogs(ctx context.Context, functionName, executedVersion, captured, funcError string) string { + requestID := idgen.UUID() + lines := invokeLogLines(executedVersion, requestID, captured, funcError) + tail := logTail(lines) + if m.logs == nil { - return + return tail } - requestID := idgen.UUID() now := m.opts.Clock.Now() group := logGroupPrefix + functionName stream := invokeLogStream(now, executedVersion, requestID) - lines := invokeLogLines(executedVersion, requestID, captured, funcError) - _, _ = m.logs.CreateLogGroup(ctx, logdriver.LogGroupConfig{Name: group}) _, _ = m.logs.CreateLogStream(ctx, group, stream) @@ -45,6 +52,26 @@ func (m *Mock) surfaceInvokeLogs(ctx context.Context, functionName, executedVers } _ = m.logs.PutLogEvents(ctx, group, stream, events) + + return tail +} + +// logTail joins the execution-log lines (newline-terminated, as the log is +// written) and keeps at most the last logTailBytes of it. +func logTail(lines []string) string { + var b strings.Builder + + for _, line := range lines { + b.WriteString(line) + b.WriteByte('\n') + } + + full := b.String() + if len(full) > logTailBytes { + full = full[len(full)-logTailBytes:] + } + + return full } // invokeLogStream builds the CloudWatch Logs stream name real Lambda uses for an diff --git a/providers/aws/lambda/functionurl.go b/providers/aws/lambda/functionurl.go index d6470b702..1faf2ade1 100644 --- a/providers/aws/lambda/functionurl.go +++ b/providers/aws/lambda/functionurl.go @@ -38,7 +38,7 @@ const invokeModeResponseStream = "RESPONSE_STREAM" // isVersionQualifier). Only one URL config may exist per (function, qualifier). // //nolint:gocritic // hugeParam: matches the functionURLManager interface signature. -func (m *Mock) CreateFunctionURLConfig(_ context.Context, cfg driver.FunctionURLConfig) (*driver.FunctionURLConfig, error) { +func (m *Mock) CreateFunctionURLConfig(ctx context.Context, cfg driver.FunctionURLConfig) (*driver.FunctionURLConfig, error) { if err := validateAuthType(cfg.AuthType); err != nil { return nil, err } @@ -52,7 +52,7 @@ func (m *Mock) CreateFunctionURLConfig(_ context.Context, cfg driver.FunctionURL fd, ok := m.funcs.Get(cfg.FunctionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", cfg.FunctionName) + return nil, m.errFunctionNotFound(ctx, cfg.FunctionName, "") } if err := validateFunctionURLQualifier(&fd, cfg.Qualifier); err != nil { @@ -97,13 +97,13 @@ func (m *Mock) CreateFunctionURLConfig(_ context.Context, cfg driver.FunctionURL // GetFunctionURLConfig returns the Function URL config for a function's // qualifier ("" and "$LATEST" both mean the unqualified $LATEST URL). -func (m *Mock) GetFunctionURLConfig(_ context.Context, functionName, qualifier string) (*driver.FunctionURLConfig, error) { +func (m *Mock) GetFunctionURLConfig(ctx context.Context, functionName, qualifier string) (*driver.FunctionURLConfig, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } cfg, ok := fd.urlConfigs[policyKey(qualifier)] @@ -118,7 +118,7 @@ func (m *Mock) GetFunctionURLConfig(_ context.Context, functionName, qualifier s // config, leaving unset fields unchanged. // //nolint:gocritic // hugeParam: matches the functionURLManager interface signature. -func (m *Mock) UpdateFunctionURLConfig(_ context.Context, cfg driver.FunctionURLConfig) (*driver.FunctionURLConfig, error) { +func (m *Mock) UpdateFunctionURLConfig(ctx context.Context, cfg driver.FunctionURLConfig) (*driver.FunctionURLConfig, error) { if err := validateAuthType(cfg.AuthType); err != nil { return nil, err } @@ -132,7 +132,7 @@ func (m *Mock) UpdateFunctionURLConfig(_ context.Context, cfg driver.FunctionURL fd, ok := m.funcs.Get(cfg.FunctionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", cfg.FunctionName) + return nil, m.errFunctionNotFound(ctx, cfg.FunctionName, "") } key := policyKey(cfg.Qualifier) @@ -165,13 +165,13 @@ func (m *Mock) UpdateFunctionURLConfig(_ context.Context, cfg driver.FunctionURL } // DeleteFunctionURLConfig removes a function's URL config for a qualifier. -func (m *Mock) DeleteFunctionURLConfig(_ context.Context, functionName, qualifier string) error { +func (m *Mock) DeleteFunctionURLConfig(ctx context.Context, functionName, qualifier string) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return m.errFunctionNotFound(ctx, functionName, "") } key := policyKey(qualifier) @@ -195,13 +195,13 @@ func (m *Mock) DeleteFunctionURLConfig(_ context.Context, functionName, qualifie // ListFunctionURLConfigs returns every Function URL config set on a function // (one per qualifier). -func (m *Mock) ListFunctionURLConfigs(_ context.Context, functionName string) ([]driver.FunctionURLConfig, error) { +func (m *Mock) ListFunctionURLConfigs(ctx context.Context, functionName string) ([]driver.FunctionURLConfig, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } out := make([]driver.FunctionURLConfig, 0, len(fd.urlConfigs)) diff --git a/providers/aws/lambda/invoke_sync.go b/providers/aws/lambda/invoke_sync.go index 61392f95a..ad39c7699 100644 --- a/providers/aws/lambda/invoke_sync.go +++ b/providers/aws/lambda/invoke_sync.go @@ -4,7 +4,6 @@ import ( "context" "fmt" - cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/recursionguard" "github.com/stackshy/cloudemu/v2/services/serverless/driver" ) @@ -31,7 +30,7 @@ func (m *Mock) InvokeSync( ) (output []byte, functionError string, err error) { name := functionNameFromARN(functionARN) if _, ok := m.funcs.Get(name); !ok { - return nil, "", cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return nil, "", m.errFunctionNotFound(ctx, name, "") } depth := recursionguard.Depth(ctx) diff --git a/providers/aws/lambda/lambda.go b/providers/aws/lambda/lambda.go index 7361c6bb1..327def3d5 100644 --- a/providers/aws/lambda/lambda.go +++ b/providers/aws/lambda/lambda.go @@ -21,6 +21,7 @@ import ( "github.com/stackshy/cloudemu/v2/internal/memstore" "github.com/stackshy/cloudemu/v2/internal/recursionguard" "github.com/stackshy/cloudemu/v2/internal/regionctx" + "github.com/stackshy/cloudemu/v2/internal/settle" logdriver "github.com/stackshy/cloudemu/v2/services/logging/driver" mondriver "github.com/stackshy/cloudemu/v2/services/monitoring/driver" "github.com/stackshy/cloudemu/v2/services/serverless/driver" @@ -186,6 +187,16 @@ type versionData struct { codeSHA string revisionID string createdAt string + // codeSize is the deployment-package size the version was cut from. A + // published version is immutable, so it is frozen here rather than read + // from the mutable $LATEST FunctionInfo. + codeSize int64 + // description is the version's own description: the PublishVersion + // Description override, or the function's description when none was given. + description string + // awsConfig freezes the AWS-only settings (VpcConfig, DeadLetterConfig, + // TracingConfig, Architectures, EphemeralStorage, Layers) at publish time. + awsConfig driver.AWSFunctionConfig } type aliasData struct { @@ -243,6 +254,9 @@ type funcData struct { // normalized, since real Lambda cannot attach provisioned concurrency to // the mutable $LATEST code). provisionedConcurrencyConfigs map[string]driver.ProvisionedConcurrencyConfig + // codeSigningConfigARN is the code signing config attached to the function + // (PutFunctionCodeSigningConfig), or "" when none is. + codeSigningConfigARN string } // Mock is an in-memory mock implementation of AWS Lambda. @@ -277,6 +291,16 @@ type Mock struct { // call (see engine_ops.go); engineSeq mints the tokens. Both under mu. engineOps map[string]uint64 engineSeq uint64 + // createSettle / updateSettle overlay the transient lifecycle a function + // reports under config.Options.AsyncSettle: State Pending (keyed by function + // name) until creation completes, and LastUpdateStatus InProgress until an + // update completes. Both are inactive unless AsyncSettle is set, and, like + // every other provider's settle windows, are not persisted: a restored + // function reports its terminal state. + createSettle *settle.Set + updateSettle *settle.Set + // cscs holds the code signing configs keyed by ARN. + cscs *memstore.Store[driver.CodeSigningConfig] // inflightMu guards inflight, the number of invocations currently executing // per function name. It backs reserved-concurrency enforcement on Invoke. inflightMu sync.Mutex @@ -328,6 +352,10 @@ func New(opts *config.Options) *Mock { handlers: make(map[string]driver.HandlerFunc), inflight: make(map[string]int), engineOps: make(map[string]uint64), + + createSettle: settle.NewSet(), + updateSettle: settle.NewSet(), + cscs: memstore.New[driver.CodeSigningConfig](), } } @@ -360,7 +388,7 @@ func (m *Mock) CreateFunction(ctx context.Context, cfg driver.FunctionConfig) (* info := driver.FunctionInfo{ Name: cfg.Name, ARN: arn, Runtime: cfg.Runtime, Handler: cfg.Handler, Role: cfg.Role, Description: cfg.Description, - Memory: cfg.Memory, Timeout: cfg.Timeout, State: "Active", + Memory: cfg.Memory, Timeout: cfg.Timeout, State: stateActive, Environment: maps.Clone(cfg.Environment), Tags: maps.Clone(cfg.Tags), LastModified: m.opts.Clock.Now().UTC().Format(time.RFC3339), CodeSHA256: codeHash(cfg.Code), CodeSize: int64(len(cfg.Code)), @@ -405,15 +433,16 @@ func validateFunctionLimits(memory, timeout int) error { return nil } -func (m *Mock) GetFunction(_ context.Context, name string) (*driver.FunctionInfo, error) { +func (m *Mock) GetFunction(ctx context.Context, name string) (*driver.FunctionInfo, error) { fd, ok := m.funcs.Get(name) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return nil, m.errFunctionNotFound(ctx, name, "") } info := fd.info info.Environment = maps.Clone(info.Environment) info.Tags = maps.Clone(info.Tags) + m.observe(&info) return &info, nil } @@ -423,7 +452,9 @@ func (m *Mock) ListFunctions(_ context.Context) ([]driver.FunctionInfo, error) { infos := make([]driver.FunctionInfo, 0, len(all)) for k := range all { - infos = append(infos, all[k].info) + info := all[k].info + m.observe(&info) + infos = append(infos, info) } return infos, nil @@ -433,14 +464,22 @@ func (m *Mock) ListFunctions(_ context.Context) ([]driver.FunctionInfo, error) { // when set, is the engine state after a code deploy. Callers hold mu. // //nolint:gocritic // hugeParam: cfg is the by-value driver config. -func (m *Mock) commitUpdate(name string, cfg driver.FunctionConfig, backed *bool) (*driver.FunctionInfo, error) { +func (m *Mock) commitUpdate( + ctx context.Context, name string, cfg driver.FunctionConfig, backed *bool, +) (*driver.FunctionInfo, error) { fd, ok := m.funcs.Get(name) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return nil, m.errFunctionNotFound(ctx, name, "") } - if backed == nil && m.engineBusy(name) { - return nil, updateInProgress(fd.info.ARN) + // A configuration update is refused while the function is still Pending + // creation or a previous update is InProgress (an AsyncSettle window or an + // engine deploy), as real Lambda does. A code update was checked when it + // reserved the name (reserveCodeUpdate) and is finalized here. + if backed == nil { + if err := m.checkUpdatable(&fd); err != nil { + return nil, err + } } info, err := m.mergeUpdate(fd.info, cfg) @@ -455,8 +494,10 @@ func (m *Mock) commitUpdate(name string, cfg driver.FunctionConfig, backed *bool } m.funcs.Set(name, fd) + m.beginUpdateSettle(name) result := info + m.observe(&result) return &result, nil } @@ -498,10 +539,16 @@ func (m *Mock) mergeUpdate(cur driver.FunctionInfo, cfg driver.FunctionConfig) ( func (m *Mock) Invoke(ctx context.Context, input driver.InvokeInput) (*driver.InvokeOutput, error) { fd, ok := m.funcs.Get(input.FunctionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", input.FunctionName) + return nil, m.errFunctionNotFound(ctx, input.FunctionName, "") + } + + // A function still Pending creation cannot be invoked + // (ResourceConflictException), matching real Lambda. + if err := m.checkInvocable(&fd); err != nil { + return nil, err } - executedVersion, err := m.resolveQualifier(&fd, input.Qualifier, input.Payload) + executedVersion, err := m.resolveQualifier(ctx, &fd, input.Qualifier, input.Payload) if err != nil { return nil, err } @@ -571,14 +618,16 @@ func (m *Mock) runInvocation( // RegisterHandler to run real logic. m.emitMetric(ctx, "Invocations", 1, dims) m.emitMetric(ctx, "Duration", m.durationMillis(start), dims) - m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, "", "") + tail := m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, "", "") payload := input.Payload if len(payload) == 0 { payload = []byte("{}") } - return &driver.InvokeOutput{StatusCode: 200, Payload: payload, ExecutedVersion: executedVersion}, nil + return &driver.InvokeOutput{ + StatusCode: 200, Payload: payload, ExecutedVersion: executedVersion, LogTail: tail, + }, nil } payload, err := h(ctx, input.Payload) @@ -588,17 +637,21 @@ func (m *Mock) runInvocation( m.emitMetric(ctx, "Invocations", 1, dims) m.emitMetric(ctx, "Errors", 1, dims) m.emitMetric(ctx, "Duration", duration, dims) - m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, "", err.Error()) + tail := m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, "", err.Error()) - return &driver.InvokeOutput{StatusCode: 500, Error: err.Error(), ExecutedVersion: executedVersion}, nil + return &driver.InvokeOutput{ + StatusCode: 500, Error: err.Error(), ExecutedVersion: executedVersion, LogTail: tail, + }, nil } m.emitMetric(ctx, "Invocations", 1, dims) m.emitMetric(ctx, "Duration", duration, dims) m.emitMetric(ctx, "ConcurrentExecutions", 1, dims) - m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, "", "") + tail := m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, "", "") - return &driver.InvokeOutput{StatusCode: 200, Payload: payload, ExecutedVersion: executedVersion}, nil + return &driver.InvokeOutput{ + StatusCode: 200, Payload: payload, ExecutedVersion: executedVersion, LogTail: tail, + }, nil } // durationMillis is the invocation's measured run time on the configured clock, @@ -631,7 +684,7 @@ func (m *Mock) resolveHandler(fd *funcData, name string) driver.HandlerFunc { // routingKey (the invoke payload) selects one deterministically (see // selectAliasVersion). An unknown qualifier is a ResourceNotFoundException, // the same error AWS returns for an alias or version that doesn't exist. -func (m *Mock) resolveQualifier(fd *funcData, qualifier string, routingKey []byte) (string, error) { +func (m *Mock) resolveQualifier(ctx context.Context, fd *funcData, qualifier string, routingKey []byte) (string, error) { if qualifier == "" || qualifier == latestVersion { return latestVersion, nil } @@ -644,7 +697,7 @@ func (m *Mock) resolveQualifier(fd *funcData, qualifier string, routingKey []byt return qualifier, nil } - return "", cerrors.Newf(cerrors.NotFound, "function version/alias not found for %s", qualifier) + return "", m.errFunctionNotFound(ctx, fd.info.Name, qualifier) } // selectAliasVersion picks the concrete version a weighted alias routes an @@ -791,14 +844,14 @@ func (m *Mock) invokeEngine( if out.Error != "" { m.emitMetric(ctx, "Errors", 1, dims) m.emitMetric(ctx, "Duration", duration, dims) - m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, out.Logs, out.Error) + out.LogTail = m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, out.Logs, out.Error) return out, nil } m.emitMetric(ctx, "Duration", duration, dims) m.emitMetric(ctx, "ConcurrentExecutions", 1, dims) - m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, out.Logs, "") + out.LogTail = m.surfaceInvokeLogs(ctx, input.FunctionName, executedVersion, out.Logs, "") return out, nil } diff --git a/providers/aws/lambda/lifecycle.go b/providers/aws/lambda/lifecycle.go new file mode 100644 index 000000000..8dd63401b --- /dev/null +++ b/providers/aws/lambda/lifecycle.go @@ -0,0 +1,141 @@ +package lambda + +import ( + "context" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/internal/idgen" + "github.com/stackshy/cloudemu/v2/internal/regionctx" + "github.com/stackshy/cloudemu/v2/internal/settle" + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// Function lifecycle values real Lambda reports on FunctionConfiguration. See +// https://docs.aws.amazon.com/lambda/latest/dg/functions-states.html and +// https://docs.aws.amazon.com/lambda/latest/api/API_FunctionConfiguration.html +const ( + stateActive = "Active" + statePending = "Pending" + + // stateReasonCreating / stateReasonCodeCreating are the StateReason and + // StateReasonCode a function reports while it is Pending creation. + stateReasonCreating = "The function is being created." + stateReasonCodeCreating = "Creating" + + lastUpdateSuccessful = "Successful" + lastUpdateInProgress = "InProgress" +) + +// errFunctionNotFound is the ResourceNotFoundException real Lambda returns for a +// function (or function version/alias) that does not exist. Its message names +// the full function ARN, qualified when the request named a version or alias: +// "Function not found: arn:aws:lambda:::function:[:]". +func (m *Mock) errFunctionNotFound(ctx context.Context, name, qualifier string) error { + arn := m.regionalFunctionARN(ctx, name) + if qualifier != "" { + arn += ":" + qualifier + } + + return cerrors.Newf(cerrors.NotFound, "Function not found: %s", arn) +} + +// regionalFunctionARN is the unqualified ARN of function name in the request's region. +func (m *Mock) regionalFunctionARN(ctx context.Context, name string) string { + return idgen.AWSARN("lambda", regionctx.RegionOr(ctx, m.opts.Region), m.opts.AccountID, "function:"+name) +} + +// beginCreateSettle opens the Pending window a freshly created function reports +// until creation completes. With config.Options.AsyncSettle off (the default) +// SettleDuration is 0 and the window is inactive, so the function is Active +// immediately (the historical behavior). +func (m *Mock) beginCreateSettle(name string) { + m.updateSettle.Clear(name) + m.createSettle.Begin(name, statePending, m.opts.Clock.Now(), + m.opts.SettleDuration(settle.DefaultLambdaCreateSettle)) +} + +// beginUpdateSettle opens the LastUpdateStatus=InProgress window an +// UpdateFunctionCode/UpdateFunctionConfiguration reports until the update +// completes. A no-op unless AsyncSettle is on. +func (m *Mock) beginUpdateSettle(name string) { + m.updateSettle.Begin(name, lastUpdateInProgress, m.opts.Clock.Now(), + m.opts.SettleDuration(settle.DefaultLambdaUpdateSettle)) +} + +// clearSettle drops a deleted function's settle windows. +func (m *Mock) clearSettle(name string) { + m.createSettle.Clear(name) + m.updateSettle.Clear(name) +} + +// isPending reports whether function name is still Pending creation. +func (m *Mock) isPending(name string) bool { + return !m.createSettle.Settled(name, m.opts.Clock.Now()) +} + +// isUpdating reports whether an update of function name is still InProgress. +func (m *Mock) isUpdating(name string) bool { + return !m.updateSettle.Settled(name, m.opts.Clock.Now()) +} + +// observe overlays the function's lifecycle onto info: State Pending with the +// Creating reason (and no LastUpdateStatus, which AWS first sets once creation +// completes) while creation settles; otherwise the stored State with +// LastUpdateStatus InProgress while an update settles, else Successful. +func (m *Mock) observe(info *driver.FunctionInfo) { + if m.isPending(info.Name) { + info.State = statePending + info.StateReason = stateReasonCreating + info.StateReasonCode = stateReasonCodeCreating + info.LastUpdateStatus = "" + + return + } + + info.StateReason = "" + info.StateReasonCode = "" + info.LastUpdateStatus = m.updateSettle.State(info.Name, m.opts.Clock.Now(), lastUpdateSuccessful) +} + +// errPending is the ResourceConflictException real Lambda returns for an +// operation on a function that is still Pending creation. +func errPending(arn string) error { + return cerrors.Newf(cerrors.AlreadyExists, + "The operation cannot be performed at this time. The resource %s is currently in the following state: "+ + "'%s'. StateReasonCode: '%s'", arn, statePending, stateReasonCodeCreating) +} + +// checkUpdatable rejects UpdateFunctionCode, UpdateFunctionConfiguration and +// PublishVersion while the function is Pending creation or an update is still +// InProgress, with the ResourceConflictException real Lambda returns. See +// https://docs.aws.amazon.com/lambda/latest/dg/functions-states.html +func (m *Mock) checkUpdatable(fd *funcData) error { + if m.isPending(fd.info.Name) { + return errPending(fd.info.ARN) + } + + return m.checkNotUpdating(fd) +} + +// checkNotUpdating rejects an operation AWS blocks while an update is +// InProgress (it also blocks TagResource): either the AsyncSettle update window +// is still open or a code deploy is still running in the function engine. +// Callers hold m.mu (engineBusy reads engineOps). +func (m *Mock) checkNotUpdating(fd *funcData) error { + if m.isUpdating(fd.info.Name) || m.engineBusy(fd.info.Name) { + return updateInProgress(fd.info.ARN) + } + + return nil +} + +// checkInvocable rejects an invocation of a function that is still Pending +// creation. An in-progress update does not block invocations (AWS keeps +// serving them while the update settles). +func (m *Mock) checkInvocable(fd *funcData) error { + if m.isPending(fd.info.Name) { + return errPending(fd.info.ARN) + } + + return nil +} diff --git a/providers/aws/lambda/parity_1518_test.go b/providers/aws/lambda/parity_1518_test.go new file mode 100644 index 000000000..9eeff760b --- /dev/null +++ b/providers/aws/lambda/parity_1518_test.go @@ -0,0 +1,431 @@ +package lambda + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// TestPublishedVersionKeepsItsCodeSizeAndConfig: a published version is +// immutable, so UpdateFunctionCode/Configuration on $LATEST must not change the +// version's CodeSize, Environment or AWS-only settings. +func TestPublishedVersionKeepsItsCodeSizeAndConfig(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + cfg := defaultFuncConfig() + cfg.Code = []byte("small") + cfg.Environment = map[string]string{"K": "v1"} + + if _, err := m.CreateFunction(ctx, cfg); err != nil { + t.Fatal(err) + } + + if err := m.SetFunctionAWSConfig(ctx, "my-func", + driver.AWSFunctionConfig{Architectures: []string{"x86_64"}}, true); err != nil { + t.Fatal(err) + } + + if _, err := m.PublishVersion(ctx, "my-func", ""); err != nil { + t.Fatal(err) + } + + if _, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{ + Code: []byte("a much larger deployment package"), Environment: map[string]string{"K": "v2"}, + }); err != nil { + t.Fatal(err) + } + + if err := m.SetFunctionAWSConfig(ctx, "my-func", + driver.AWSFunctionConfig{Architectures: []string{"arm64"}}, false); err != nil { + t.Fatal(err) + } + + vers, err := m.ListVersions(ctx, "my-func") + if err != nil { + t.Fatal(err) + } + + var v1, latest driver.FunctionVersion + + for _, v := range vers { + switch v.Version { + case "1": + v1 = v + case latestVersion: + latest = v + } + } + + if v1.CodeSize != int64(len("small")) { + t.Errorf("version 1 CodeSize = %d, want %d", v1.CodeSize, len("small")) + } + + if latest.CodeSize != int64(len("a much larger deployment package")) { + t.Errorf("$LATEST CodeSize = %d", latest.CodeSize) + } + + if v1.Environment["K"] != "v1" { + t.Errorf("version 1 Environment = %v, want K=v1", v1.Environment) + } + + awsCfg, err := m.GetVersionAWSConfig(ctx, "my-func", "1") + if err != nil { + t.Fatal(err) + } + + if len(awsCfg.Architectures) != 1 || awsCfg.Architectures[0] != "x86_64" { + t.Errorf("version 1 Architectures = %v, want [x86_64]", awsCfg.Architectures) + } + + // The frozen fields survive a snapshot round-trip. + snap, err := m.Snapshot(ctx, false) + if err != nil { + t.Fatal(err) + } + + restored := newTestMock() + if err := restored.Restore(ctx, snap); err != nil { + t.Fatal(err) + } + + rv, err := restored.ListVersions(ctx, "my-func") + if err != nil { + t.Fatal(err) + } + + if rv[1].CodeSize != int64(len("small")) { + t.Errorf("restored version 1 CodeSize = %d", rv[1].CodeSize) + } + + rCfg, err := restored.GetVersionAWSConfig(ctx, "my-func", "1") + if err != nil || len(rCfg.Architectures) != 1 || rCfg.Architectures[0] != "x86_64" { + t.Errorf("restored version 1 Architectures = %v (err %v)", rCfg.Architectures, err) + } +} + +func newSettleMock() (*Mock, *config.FakeClock) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + + return New(config.NewOptions(config.WithClock(fc), config.WithRegion("us-east-1"), config.WithAsyncSettle())), fc +} + +// TestAsyncSettlePendingThenActive: under AsyncSettle a new function reports +// State Pending (StateReasonCode Creating, no LastUpdateStatus) and rejects +// Invoke/Update/PublishVersion with a conflict until creation completes. +func TestAsyncSettlePendingThenActive(t *testing.T) { + ctx := context.Background() + m, fc := newSettleMock() + + created, err := m.CreateFunction(ctx, defaultFuncConfig()) + if err != nil { + t.Fatal(err) + } + + if created.State != statePending || created.StateReasonCode != "Creating" || created.LastUpdateStatus != "" { + t.Fatalf("create = %q/%q/%q, want Pending/Creating/", + created.State, created.StateReasonCode, created.LastUpdateStatus) + } + + _, invErr := m.Invoke(ctx, driver.InvokeInput{FunctionName: "my-func"}) + _, updErr := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Timeout: 10}) + _, pubErr := m.PublishVersion(ctx, "my-func", "") + + for name, err := range map[string]error{"invoke": invErr, "update": updErr, "publish": pubErr} { + if !cerrors.IsAlreadyExists(err) || !strings.Contains(err.Error(), "'Pending'") { + t.Errorf("%s while Pending: err = %v, want ResourceConflict naming Pending", name, err) + } + } + + fc.Advance(2 * time.Second) + + got, err := m.GetFunction(ctx, "my-func") + if err != nil { + t.Fatal(err) + } + + if got.State != stateActive || got.LastUpdateStatus != lastUpdateSuccessful || got.StateReasonCode != "" { + t.Fatalf("settled = %q/%q/%q, want Active/Successful/", got.State, got.LastUpdateStatus, got.StateReasonCode) + } +} + +// TestAsyncSettleUpdateInProgress: an update reports LastUpdateStatus +// InProgress (State stays Active); a second update, PublishVersion and +// TagResource conflict, but Invoke still works. +func TestAsyncSettleUpdateInProgress(t *testing.T) { + ctx := context.Background() + m, fc := newSettleMock() + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + fc.Advance(2 * time.Second) + + upd, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Timeout: 10}) + if err != nil { + t.Fatal(err) + } + + if upd.State != stateActive || upd.LastUpdateStatus != lastUpdateInProgress { + t.Fatalf("update = %q/%q, want Active/InProgress", upd.State, upd.LastUpdateStatus) + } + + if _, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Timeout: 11}); !cerrors.IsAlreadyExists(err) { + t.Errorf("second update err = %v, want conflict", err) + } + + if _, err := m.PublishVersion(ctx, "my-func", ""); !cerrors.IsAlreadyExists(err) { + t.Errorf("publish err = %v, want conflict", err) + } + + if err := m.TagFunction(ctx, "my-func", map[string]string{"a": "b"}); !cerrors.IsAlreadyExists(err) { + t.Errorf("tag err = %v, want conflict", err) + } + + // PublishVersionOnWrite (Publish=true on the write) is not blocked. + if _, err := m.PublishVersionOnWrite(ctx, "my-func", ""); err != nil { + t.Errorf("publish-on-write err = %v", err) + } + + if _, err := m.Invoke(ctx, driver.InvokeInput{FunctionName: "my-func"}); err != nil { + t.Errorf("invoke during update err = %v, want nil", err) + } + + fc.Advance(2 * time.Second) + + got, err := m.GetFunction(ctx, "my-func") + if err != nil { + t.Fatal(err) + } + + if got.LastUpdateStatus != lastUpdateSuccessful { + t.Fatalf("settled LastUpdateStatus = %q, want Successful", got.LastUpdateStatus) + } +} + +// TestDefaultSettleIsImmediate: without AsyncSettle nothing changes: Active / +// Successful right away and back-to-back updates succeed. +func TestDefaultSettleIsImmediate(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + created, err := m.CreateFunction(ctx, defaultFuncConfig()) + if err != nil { + t.Fatal(err) + } + + if created.State != stateActive || created.LastUpdateStatus != lastUpdateSuccessful { + t.Fatalf("create = %q/%q", created.State, created.LastUpdateStatus) + } + + for i := 0; i < 2; i++ { + if _, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Timeout: 10 + i}); err != nil { + t.Fatalf("update %d: %v", i, err) + } + } +} + +// TestInvokeReturnsLogTail: every invocation renders its execution log; the +// tail carries the START/END/REPORT lines and the handler error. +func TestInvokeReturnsLogTail(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + out, err := m.Invoke(ctx, driver.InvokeInput{FunctionName: "my-func", Payload: []byte(`{}`)}) + if err != nil { + t.Fatal(err) + } + + for _, want := range []string{"START RequestId: ", "Version: $LATEST", "END RequestId: ", "REPORT RequestId: "} { + if !strings.Contains(out.LogTail, want) { + t.Errorf("LogTail missing %q:\n%s", want, out.LogTail) + } + } + + m.RegisterHandler("my-func", func(context.Context, []byte) ([]byte, error) { + return nil, cerrors.New(cerrors.Internal, "boom") + }) + + out, err = m.Invoke(ctx, driver.InvokeInput{FunctionName: "my-func"}) + if err != nil { + t.Fatal(err) + } + + if !strings.Contains(out.LogTail, "[ERROR]") || !strings.Contains(out.LogTail, "boom") { + t.Errorf("error LogTail = %q", out.LogTail) + } +} + +func TestLogTailKeepsLast4KB(t *testing.T) { + lines := []string{strings.Repeat("a", 5000), "END"} + + got := logTail(lines) + if len(got) != logTailBytes || !strings.HasSuffix(got, "END\n") { + t.Fatalf("tail len = %d, suffix ok = %v", len(got), strings.HasSuffix(got, "END\n")) + } +} + +// TestGetAccountSettings reports the default limits and live usage. +func TestGetAccountSettings(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + cfg := defaultFuncConfig() + cfg.Code = []byte("12345") + + if _, err := m.CreateFunction(ctx, cfg); err != nil { + t.Fatal(err) + } + + if _, err := m.PublishVersion(ctx, "my-func", ""); err != nil { + t.Fatal(err) + } + + if err := m.PutFunctionConcurrency(ctx, driver.ConcurrencyConfig{ + FunctionName: "my-func", ReservedConcurrentExecutions: 50, + }); err != nil { + t.Fatal(err) + } + + s, err := m.GetAccountSettings(ctx) + if err != nil { + t.Fatal(err) + } + + if s.ConcurrentExecutions != 1000 || s.UnreservedConcurrentExecutions != 950 { + t.Errorf("concurrency = %d/%d, want 1000/950", s.ConcurrentExecutions, s.UnreservedConcurrentExecutions) + } + + if s.CodeSizeZipped != 52428800 || s.CodeSizeUnzipped != 262144000 { + t.Errorf("code size limits = %d/%d", s.CodeSizeZipped, s.CodeSizeUnzipped) + } + + if s.FunctionCount != 1 || s.TotalCodeSizeUsed != 10 { + t.Errorf("usage = %d functions / %d bytes, want 1 / 10 ($LATEST + version 1)", s.FunctionCount, s.TotalCodeSizeUsed) + } +} + +// TestCodeSigningConfigLifecycle covers CRUD, association and the in-use +// delete conflict, plus snapshot persistence. +func TestCodeSigningConfigLifecycle(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + if _, err := m.CreateCodeSigningConfig(ctx, driver.CodeSigningConfig{}); !cerrors.IsInvalidArgument(err) { + t.Fatalf("create without publishers err = %v, want InvalidArgument", err) + } + + csc, err := m.CreateCodeSigningConfig(ctx, driver.CodeSigningConfig{ + Description: "d", + SigningProfileVersionARNs: []string{"arn:aws:signer:us-east-1:123456789012:/signing-profiles/p/abc"}, + }) + if err != nil { + t.Fatal(err) + } + + if !strings.HasPrefix(csc.ID, "csc-") || len(csc.ID) != len("csc-")+17 || + csc.ARN != "arn:aws:lambda:us-east-1:123456789012:code-signing-config:"+csc.ID { + t.Fatalf("id/arn = %q / %q", csc.ID, csc.ARN) + } + + if csc.UntrustedArtifactOnDeployment != "Warn" { + t.Errorf("default policy = %q, want Warn", csc.UntrustedArtifactOnDeployment) + } + + enforce := "Enforce" + + upd, err := m.UpdateCodeSigningConfig(ctx, csc.ARN, driver.CodeSigningConfigUpdate{UntrustedArtifactOnDeployment: &enforce}) + if err != nil || upd.UntrustedArtifactOnDeployment != "Enforce" || upd.Description != "d" { + t.Fatalf("update = %+v, %v", upd, err) + } + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + if err := m.PutFunctionCodeSigningConfig(ctx, "my-func", "arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-00000000000000000"); !cerrors.IsNotFound(err) { + t.Fatalf("put unknown csc err = %v", err) + } + + if err := m.PutFunctionCodeSigningConfig(ctx, "my-func", csc.ARN); err != nil { + t.Fatal(err) + } + + fns, err := m.ListFunctionsByCodeSigningConfig(ctx, csc.ARN) + if err != nil || len(fns) != 1 || !strings.HasSuffix(fns[0], ":function:my-func") { + t.Fatalf("list functions = %v, %v", fns, err) + } + + if err := m.DeleteCodeSigningConfig(ctx, csc.ARN); !cerrors.IsAlreadyExists(err) { + t.Fatalf("delete in-use err = %v, want conflict", err) + } + + snap, err := m.Snapshot(ctx, false) + if err != nil { + t.Fatal(err) + } + + restored := newTestMock() + if err := restored.Restore(ctx, snap); err != nil { + t.Fatal(err) + } + + if got, _ := restored.GetFunctionCodeSigningConfig(ctx, "my-func"); got != csc.ARN { + t.Errorf("restored association = %q", got) + } + + if _, err := restored.GetCodeSigningConfig(ctx, csc.ARN); err != nil { + t.Errorf("restored csc: %v", err) + } + + if err := m.DeleteFunctionCodeSigningConfig(ctx, "my-func"); err != nil { + t.Fatal(err) + } + + if err := m.DeleteCodeSigningConfig(ctx, csc.ARN); err != nil { + t.Fatal(err) + } + + if _, err := m.GetCodeSigningConfig(ctx, csc.ARN); !cerrors.IsNotFound(err) { + t.Fatalf("get deleted err = %v", err) + } + + list, _ := m.ListCodeSigningConfigs(ctx) + if len(list) != 0 { + t.Fatalf("list after delete = %d", len(list)) + } + + _ = json.Valid(snap) +} + +// TestFunctionNotFoundMessageNamesARN matches real Lambda's +// "Function not found: [:]". +func TestFunctionNotFoundMessageNamesARN(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + _, err := m.GetFunction(ctx, "nope") + if got := cerrors.Message(err); got != "Function not found: arn:aws:lambda:us-east-1:123456789012:function:nope" { + t.Errorf("GetFunction message = %q", got) + } + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + _, err = m.Invoke(ctx, driver.InvokeInput{FunctionName: "my-func", Qualifier: "PROD"}) + if got := cerrors.Message(err); got != "Function not found: arn:aws:lambda:us-east-1:123456789012:function:my-func:PROD" { + t.Errorf("Invoke qualifier message = %q", got) + } +} diff --git a/providers/aws/lambda/policy.go b/providers/aws/lambda/policy.go index 38a30f682..7f4ab3e89 100644 --- a/providers/aws/lambda/policy.go +++ b/providers/aws/lambda/policy.go @@ -33,7 +33,9 @@ func policyKey(qualifier string) string { // published version or alias. AWS stores a separate policy per qualifier. The // statements are evaluated by the wire server's Lambda handler, and only under // EnforceAuth (see PolicyStatements). -func (m *Mock) AddPermission(_ context.Context, functionName, qualifier string, stmt driver.PermissionStatement) error { +// +//nolint:gocritic // hugeParam: stmt mirrors the policyManager interface signature. +func (m *Mock) AddPermission(ctx context.Context, functionName, qualifier string, stmt driver.PermissionStatement) error { if stmt.StatementID == "" { return cerrors.New(cerrors.InvalidArgument, "StatementId is required") } @@ -58,13 +60,13 @@ func (m *Mock) AddPermission(_ context.Context, functionName, qualifier string, fd, ok := m.funcs.Get(functionName) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return m.errFunctionNotFound(ctx, functionName, "") } // A qualifier must name a version/alias that exists; a grant on a missing one // is a ResourceNotFoundException, matching real Lambda. Routing key is nil: // this only validates existence, not which weighted version traffic hits. - if _, err := m.resolveQualifier(&fd, qualifier, nil); err != nil { + if _, err := m.resolveQualifier(ctx, &fd, qualifier, nil); err != nil { return err } @@ -89,7 +91,7 @@ func (m *Mock) AddPermission(_ context.Context, functionName, qualifier string, // RemovePermission drops a statement from a function's (qualifier-scoped) // resource-based policy. -func (m *Mock) RemovePermission(_ context.Context, functionName, qualifier, statementID string) error { +func (m *Mock) RemovePermission(ctx context.Context, functionName, qualifier, statementID string) error { // $LATEST has no policy of its own (AddPermission refuses it), so a // $LATEST-qualified remove is refused too rather than editing the // unqualified function's policy. @@ -102,7 +104,7 @@ func (m *Mock) RemovePermission(_ context.Context, functionName, qualifier, stat fd, ok := m.funcs.Get(functionName) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return m.errFunctionNotFound(ctx, functionName, "") } key := policyKey(qualifier) @@ -120,13 +122,13 @@ func (m *Mock) RemovePermission(_ context.Context, functionName, qualifier, stat // JSON document, matching the shape the AWS SDK expects (IAM policy with Sid/ // Principal/Action/Resource per statement). Returns NotFound when no policy // exists for the qualifier. -func (m *Mock) GetPolicy(_ context.Context, functionName, qualifier string) (string, error) { +func (m *Mock) GetPolicy(ctx context.Context, functionName, qualifier string) (string, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return "", cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return "", m.errFunctionNotFound(ctx, functionName, "") } key := policyKey(qualifier) @@ -216,13 +218,13 @@ func statementCondition(s *driver.PermissionStatement) map[string]any { // policy for qualifier, and the function ARN that policy applies to (the // qualified ARN for a version or alias). A qualifier with no policy returns no // statements; a missing function is NotFound. -func (m *Mock) PolicyStatements(_ context.Context, functionName, qualifier string) (string, []driver.PermissionStatement, error) { +func (m *Mock) PolicyStatements(ctx context.Context, functionName, qualifier string) (string, []driver.PermissionStatement, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return "", nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return "", nil, m.errFunctionNotFound(ctx, functionName, "") } key := policyKey(qualifier) diff --git a/providers/aws/lambda/provisioned_concurrency.go b/providers/aws/lambda/provisioned_concurrency.go index bdd416cb7..530c0721f 100644 --- a/providers/aws/lambda/provisioned_concurrency.go +++ b/providers/aws/lambda/provisioned_concurrency.go @@ -33,7 +33,7 @@ const minProvisionedConcurrentExecutions = 1 // //nolint:gocritic // hugeParam: interface method signature cannot be changed. func (m *Mock) PutFunctionProvisionedConcurrencyConfig( - _ context.Context, cfg driver.ProvisionedConcurrencyConfig, + ctx context.Context, cfg driver.ProvisionedConcurrencyConfig, ) (*driver.ProvisionedConcurrencyConfig, error) { if cfg.RequestedProvisionedConcurrentExecutions < minProvisionedConcurrentExecutions { return nil, cerrors.Newf(cerrors.InvalidArgument, @@ -46,10 +46,10 @@ func (m *Mock) PutFunctionProvisionedConcurrencyConfig( fd, ok := m.funcs.Get(cfg.FunctionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", cfg.FunctionName) + return nil, m.errFunctionNotFound(ctx, cfg.FunctionName, "") } - resolved, err := m.resolveQualifier(&fd, cfg.Qualifier, nil) + resolved, err := m.resolveQualifier(ctx, &fd, cfg.Qualifier, nil) if err != nil { return nil, err } @@ -94,14 +94,14 @@ func (m *Mock) PutFunctionProvisionedConcurrencyConfig( // config for a function's version/alias, or NotFound // (ProvisionedConcurrencyConfigNotFoundException on the wire) when none is set. func (m *Mock) GetFunctionProvisionedConcurrencyConfig( - _ context.Context, functionName, qualifier string, + ctx context.Context, functionName, qualifier string, ) (*driver.ProvisionedConcurrencyConfig, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } cfg, ok := fd.provisionedConcurrencyConfigs[qualifier] @@ -117,13 +117,13 @@ func (m *Mock) GetFunctionProvisionedConcurrencyConfig( // DeleteFunctionProvisionedConcurrencyConfig removes the provisioned-concurrency // config for a function's version/alias. -func (m *Mock) DeleteFunctionProvisionedConcurrencyConfig(_ context.Context, functionName, qualifier string) error { +func (m *Mock) DeleteFunctionProvisionedConcurrencyConfig(ctx context.Context, functionName, qualifier string) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return m.errFunctionNotFound(ctx, functionName, "") } if _, exists := fd.provisionedConcurrencyConfigs[qualifier]; !exists { @@ -149,14 +149,14 @@ func (m *Mock) DeleteFunctionProvisionedConcurrencyConfig(_ context.Context, fun // config set on a function (one per qualifier), backing AWS // ListProvisionedConcurrencyConfigs. func (m *Mock) ListFunctionProvisionedConcurrencyConfigs( - _ context.Context, functionName string, + ctx context.Context, functionName string, ) ([]driver.ProvisionedConcurrencyConfig, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } out := make([]driver.ProvisionedConcurrencyConfig, 0, len(fd.provisionedConcurrencyConfigs)) diff --git a/providers/aws/lambda/snapshot.go b/providers/aws/lambda/snapshot.go index fccb5ca93..9d8d874ef 100644 --- a/providers/aws/lambda/snapshot.go +++ b/providers/aws/lambda/snapshot.go @@ -29,6 +29,8 @@ type lambdaSnapshot struct { Funcs map[string]*funcSnapshot `json:"funcs,omitempty"` Layers map[string]*layerSnapshot `json:"layers,omitempty"` Mappings json.RawMessage `json:"mappings,omitempty"` + // CodeSigningConfigs is the code signing config store, keyed by ARN. + CodeSigningConfigs json.RawMessage `json:"codeSigningConfigs,omitempty"` } // funcSnapshot mirrors funcData. Versions carry each published version's config @@ -58,6 +60,8 @@ type funcSnapshot struct { // ProvisionedConcurrencyConfigs is the provisioned-concurrency config per // qualifier (a published version or alias name). ProvisionedConcurrencyConfigs map[string]driver.ProvisionedConcurrencyConfig `json:"provisionedConcurrencyConfigs,omitempty"` + // CodeSigningConfigARN is the attached code signing config, if any. + CodeSigningConfigARN string `json:"codeSigningConfigArn,omitempty"` } // versionSnapshot mirrors versionData (all fields unexported). Config carries the @@ -69,6 +73,12 @@ type versionSnapshot struct { CodeSHA string `json:"codeSha,omitempty"` RevisionID string `json:"revisionId,omitempty"` CreatedAt string `json:"createdAt,omitempty"` + // CodeSize, Description and AWSConfig are the version's frozen package + // size, description and AWS-only settings. A snapshot taken before they + // were tracked restores them empty. + CodeSize int64 `json:"codeSize,omitempty"` + Description string `json:"description,omitempty"` + AWSConfig *driver.AWSFunctionConfig `json:"awsConfig,omitempty"` } // layerSnapshot mirrors layerData; its version store holds a fully-exported @@ -133,6 +143,15 @@ func (m *Mock) Snapshot(_ context.Context, _ bool) (json.RawMessage, error) { snap.Mappings = mappings + if m.cscs.Len() > 0 { + cscs, err := m.cscs.Snapshot() + if err != nil { + return nil, fmt.Errorf("lambda: snapshot code signing configs: %w", err) + } + + snap.CodeSigningConfigs = cscs + } + return json.Marshal(snap) } @@ -163,12 +182,15 @@ func snapshotFunc(fd *funcData) *funcSnapshot { Concurrency: fd.concurrency, Policies: fd.policies, URLConfigs: fd.urlConfigs, AWSConfig: fd.awsConfig, EventInvokeConfigs: fd.eventInvokeConfigs, ProvisionedConcurrencyConfigs: fd.provisionedConcurrencyConfigs, + CodeSigningConfigARN: fd.codeSigningConfigARN, } for _, v := range fd.versions { + awsCfg := cloneAWSConfig(&v.awsConfig) fs.Versions = append(fs.Versions, &versionSnapshot{ Config: v.config, Version: v.version, CodeSHA: v.codeSHA, RevisionID: v.revisionID, CreatedAt: v.createdAt, + CodeSize: v.codeSize, Description: v.description, AWSConfig: &awsCfg, }) } @@ -198,6 +220,9 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { for name, fs := range snap.Funcs { m.funcs.Set(name, m.restoreFunc(name, fs)) + // Settle windows are not persisted: a restored function reports its + // terminal state. + m.clearSettle(name) } m.mu.Unlock() @@ -216,6 +241,12 @@ func (m *Mock) Restore(_ context.Context, data json.RawMessage) error { } } + if len(snap.CodeSigningConfigs) > 0 { + if err := m.cscs.LoadSnapshot(snap.CodeSigningConfigs); err != nil { + return fmt.Errorf("lambda: restore code signing configs: %w", err) + } + } + return nil } @@ -257,6 +288,7 @@ func (m *Mock) restoreFunc(name string, fs *funcSnapshot) funcData { policies: fs.Policies, urlConfigs: legacyURLConfigs(fs), awsConfig: fs.AWSConfig, eventInvokeConfigs: fs.EventInvokeConfigs, provisionedConcurrencyConfigs: fs.ProvisionedConcurrencyConfigs, + codeSigningConfigARN: fs.CodeSigningConfigARN, } // Re-link the live handler if the host process has registered one for this @@ -266,10 +298,16 @@ func (m *Mock) restoreFunc(name string, fs *funcSnapshot) funcData { m.handlersMu.RUnlock() for _, v := range fs.Versions { - fd.versions = append(fd.versions, &versionData{ + vd := &versionData{ config: v.Config, version: v.Version, codeSHA: v.CodeSHA, revisionID: v.RevisionID, createdAt: v.CreatedAt, - }) + codeSize: v.CodeSize, description: v.Description, + } + if v.AWSConfig != nil { + vd.awsConfig = cloneAWSConfig(v.AWSConfig) + } + + fd.versions = append(fd.versions, vd) } for k, a := range fs.Aliases { diff --git a/providers/aws/lambda/tags.go b/providers/aws/lambda/tags.go index 5a0b3ab39..4b5d0995b 100644 --- a/providers/aws/lambda/tags.go +++ b/providers/aws/lambda/tags.go @@ -3,22 +3,21 @@ package lambda import ( "context" "maps" - - cerrors "github.com/stackshy/cloudemu/v2/errors" ) // TagFunction adds or overwrites tags on a function (Lambda TagResource). -func (m *Mock) TagFunction(_ context.Context, name string, tags map[string]string) error { +func (m *Mock) TagFunction(ctx context.Context, name string, tags map[string]string) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(name) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return m.errFunctionNotFound(ctx, name, "") } - if m.engineBusy(name) { - return updateInProgress(fd.info.ARN) + // Real Lambda rejects TagResource while an update is InProgress. + if err := m.checkNotUpdating(&fd); err != nil { + return err } // Copy before editing: GetFunction and ListFunctionTags read the stored @@ -34,13 +33,13 @@ func (m *Mock) TagFunction(_ context.Context, name string, tags map[string]strin } // UntagFunction removes tags by key from a function (Lambda UntagResource). -func (m *Mock) UntagFunction(_ context.Context, name string, keys []string) error { +func (m *Mock) UntagFunction(ctx context.Context, name string, keys []string) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(name) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return m.errFunctionNotFound(ctx, name, "") } next := maps.Clone(fd.info.Tags) @@ -56,10 +55,10 @@ func (m *Mock) UntagFunction(_ context.Context, name string, keys []string) erro } // ListFunctionTags returns a function's tags (Lambda ListTags). -func (m *Mock) ListFunctionTags(_ context.Context, name string) (map[string]string, error) { +func (m *Mock) ListFunctionTags(ctx context.Context, name string) (map[string]string, error) { fd, ok := m.funcs.Get(name) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return nil, m.errFunctionNotFound(ctx, name, "") } return maps.Clone(fd.info.Tags), nil diff --git a/providers/aws/lambda/versions.go b/providers/aws/lambda/versions.go index 07c73a51f..8408afe17 100644 --- a/providers/aws/lambda/versions.go +++ b/providers/aws/lambda/versions.go @@ -2,6 +2,7 @@ package lambda import ( "context" + "maps" "strconv" "time" @@ -18,16 +19,42 @@ const latestVersion = "$LATEST" const latestVersionPublished = "$LATEST.PUBLISHED" // PublishVersion snapshots the current function state as an immutable version. -func (m *Mock) PublishVersion(_ context.Context, functionName, description string) (*driver.FunctionVersion, error) { +// Like real Lambda it fails with ResourceConflictException while the function is +// still Pending creation or an update is InProgress (only observable under +// config.Options.AsyncSettle). +func (m *Mock) PublishVersion(ctx context.Context, functionName, description string) (*driver.FunctionVersion, error) { + return m.publishVersion(ctx, functionName, description, true) +} + +// PublishVersionOnWrite publishes a version as part of the write that just +// changed the function: CreateFunction / UpdateFunctionCode with Publish=true. +// AWS performs that publish atomically with the create/update, so unlike a +// standalone PublishVersion it is not rejected by the Pending/InProgress window +// the write itself opened. It is an AWS-only capability the Lambda server +// handler type-asserts for. +func (m *Mock) PublishVersionOnWrite(ctx context.Context, functionName, description string) (*driver.FunctionVersion, error) { + return m.publishVersion(ctx, functionName, description, false) +} + +func (m *Mock) publishVersion( + ctx context.Context, functionName, description string, gate bool, +) (*driver.FunctionVersion, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } - if m.engineBusy(functionName) { + // A standalone PublishVersion is refused while the function is Pending or an + // update is InProgress; a code deploy still running in the engine always + // blocks it. + if gate { + if err := m.checkUpdatable(&fd); err != nil { + return nil, err + } + } else if m.engineBusy(functionName) { return nil, updateInProgress(fd.info.ARN) } @@ -43,34 +70,51 @@ func (m *Mock) PublishVersion(_ context.Context, functionName, description strin verNum := fd.nextVersion fd.nextVersion++ - verStr := strconv.Itoa(verNum) - sha := fd.info.CodeSHA256 - rev := fd.info.RevisionID - now := m.opts.Clock.Now().UTC().Format(time.RFC3339) + // The PublishVersion Description overrides the function's description; when + // omitted the version keeps the description the function had when it was cut. + verDesc := description + if verDesc == "" { + verDesc = fd.info.Description + } vd := &versionData{ - config: snapshotConfig(&fd.info), - version: verStr, - codeSHA: sha, - revisionID: rev, - createdAt: now, + config: snapshotConfig(&fd.info), + version: strconv.Itoa(verNum), + codeSHA: fd.info.CodeSHA256, + codeSize: fd.info.CodeSize, + revisionID: fd.info.RevisionID, + createdAt: m.opts.Clock.Now().UTC().Format(time.RFC3339), + description: verDesc, + awsConfig: cloneAWSConfig(&fd.awsConfig), } fd.versions = append(fd.versions, vd) m.funcs.Set(functionName, fd) - return versionResult(functionName, vd, description), nil + return versionResult(functionName, vd, ""), nil } // versionResult renders a published version's driver.FunctionVersion, used both // when a fresh version is cut and when a no-change PublishVersion returns the -// existing version. description overrides the stored version description, matching -// PublishVersion's Description parameter. +// existing version. A non-empty description overrides the stored version +// description, matching PublishVersion's Description parameter. func versionResult(functionName string, v *versionData, description string) *driver.FunctionVersion { - return &driver.FunctionVersion{ + out := versionInfo(functionName, v) + if description != "" { + out.Description = description + } + + return &out +} + +// versionInfo renders a published version from its frozen snapshot only, never +// from the mutable $LATEST state. +func versionInfo(functionName string, v *versionData) driver.FunctionVersion { + return driver.FunctionVersion{ FunctionName: functionName, Version: v.version, - Description: description, + Description: v.description, CodeSHA256: v.codeSHA, + CodeSize: v.codeSize, RevisionID: v.revisionID, CreatedAt: v.createdAt, Runtime: v.config.Runtime, @@ -78,14 +122,15 @@ func versionResult(functionName string, v *versionData, description string) *dri Memory: v.config.Memory, Timeout: v.config.Timeout, Role: v.config.Role, + Environment: maps.Clone(v.config.Environment), } } // ListVersions returns all published versions for a function. -func (m *Mock) ListVersions(_ context.Context, functionName string) ([]driver.FunctionVersion, error) { +func (m *Mock) ListVersions(ctx context.Context, functionName string) ([]driver.FunctionVersion, error) { fd, ok := m.funcs.Get(functionName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "function %s not found", functionName) + return nil, m.errFunctionNotFound(ctx, functionName, "") } result := make([]driver.FunctionVersion, 0, len(fd.versions)+1) @@ -93,33 +138,50 @@ func (m *Mock) ListVersions(_ context.Context, functionName string) ([]driver.Fu result = append(result, driver.FunctionVersion{ FunctionName: functionName, Version: latestVersion, + Description: fd.info.Description, CodeSHA256: fd.info.CodeSHA256, + CodeSize: fd.info.CodeSize, RevisionID: fd.info.RevisionID, Runtime: fd.info.Runtime, Handler: fd.info.Handler, Memory: fd.info.Memory, Timeout: fd.info.Timeout, Role: fd.info.Role, + Environment: maps.Clone(fd.info.Environment), }) for _, v := range fd.versions { - result = append(result, driver.FunctionVersion{ - FunctionName: functionName, - Version: v.version, - CodeSHA256: v.codeSHA, - RevisionID: v.revisionID, - CreatedAt: v.createdAt, - Runtime: v.config.Runtime, - Handler: v.config.Handler, - Memory: v.config.Memory, - Timeout: v.config.Timeout, - Role: v.config.Role, - }) + result = append(result, versionInfo(functionName, v)) } return result, nil } +// GetVersionAWSConfig returns the AWS-only settings (VpcConfig, +// DeadLetterConfig, TracingConfig, Architectures, EphemeralStorage, Layers) of +// one function version: the live settings for "" / $LATEST, or the settings a +// published version froze when it was cut. It is an AWS-only capability the +// Lambda server handler type-asserts for, so a published version reports its own +// configuration rather than whatever $LATEST was changed to afterwards. +func (m *Mock) GetVersionAWSConfig(ctx context.Context, name, version string) (driver.AWSFunctionConfig, error) { + fd, ok := m.funcs.Get(name) + if !ok { + return driver.AWSFunctionConfig{}, m.errFunctionNotFound(ctx, name, "") + } + + if version == "" || version == latestVersion { + return cloneAWSConfig(&fd.awsConfig), nil + } + + for _, v := range fd.versions { + if v.version == version { + return cloneAWSConfig(&v.awsConfig), nil + } + } + + return driver.AWSFunctionConfig{}, m.errFunctionNotFound(ctx, name, version) +} + // DeleteVersion deletes a single published version of a function, matching the // AWS Lambda DeleteFunction Qualifier semantics: only a numeric published version // can be removed, deleting $LATEST is rejected, and a version an alias still @@ -129,13 +191,13 @@ func (m *Mock) ListVersions(_ context.Context, functionName string) ([]driver.Fu // whole function (all versions and aliases) via DeleteFunction. It has no Azure // Functions / GCP Cloud Functions equivalent, so it is an AWS-only capability // asserted by the handler rather than part of the portable Serverless driver. -func (m *Mock) DeleteVersion(_ context.Context, name, qualifier string) error { +func (m *Mock) DeleteVersion(ctx context.Context, name, qualifier string) error { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(name) if !ok { - return cerrors.Newf(cerrors.NotFound, "function %s not found", name) + return m.errFunctionNotFound(ctx, name, "") } if qualifier == latestVersion || qualifier == latestVersionPublished { diff --git a/server/aws/authz_matrix_lambda_test.go b/server/aws/authz_matrix_lambda_test.go index bea0f0247..07d69da13 100644 --- a/server/aws/authz_matrix_lambda_test.go +++ b/server/aws/authz_matrix_lambda_test.go @@ -390,10 +390,8 @@ func TestAuthzMatrixLambdaUnroutedOps(t *testing.T) { u := userWithPolicy(t, cloud, "s3all", allow("s3:*", "lambda:*")) for _, rq := range []sreq{ - {method: http.MethodGet, path: "/2016-08-19/account-settings", service: "lambda"}, {path: "/2014-11-13/functions/fn1/invoke-async/", ctype: "application/json", body: `{}`, service: "lambda"}, {path: "/2021-11-15/functions/fn1/response-streaming-invocations", ctype: "application/json", body: `{}`, service: "lambda"}, - {path: "/2020-04-22/code-signing-configs/", ctype: "application/json", body: `{}`, service: "lambda"}, {method: http.MethodPut, path: "/2021-07-20/functions/fn1/runtime-management-config", ctype: "application/json", body: `{}`, service: "lambda"}, } { @@ -410,6 +408,51 @@ func TestAuthzMatrixLambdaUnroutedOps(t *testing.T) { } } +// TestAuthzMatrixLambdaAccountAndCodeSigning checks GetAccountSettings and the +// code signing config operations are routed to Lambda and authorized on their +// own IAM actions: GetAccountSettings and CreateCodeSigningConfig on "*", the +// rest on the code signing config ARN. +func TestAuthzMatrixLambdaAccountAndCodeSigning(t *testing.T) { + ts, cloud := matrixServer(t, nil) + newFunction(t, cloud, "fn1", nil) + + u := userWithPolicy(t, cloud, "acct", allow("lambda:GetAccountSettings")) + + status, body := doSigned(t, ts, u, sreq{method: http.MethodGet, path: "/2016-08-19/account-settings", service: "lambda"}) + if status != http.StatusOK || !strings.Contains(body, `"ConcurrentExecutions":1000`) { + t.Fatalf("GetAccountSettings: status %d %s, want 200", status, body) + } + + create := sreq{ + path: "/2020-04-22/code-signing-configs", ctype: "application/json", service: "lambda", + body: `{"AllowedPublishers":{"SigningProfileVersionArns":["arn:aws:signer:us-east-1:123456789012:/signing-profiles/p/v"]}}`, + } + + status, body = doSigned(t, ts, u, create) + wantDenied(t, status, body, lambdaDeny) + + creator := userWithPolicy(t, cloud, "csc", allow("lambda:CreateCodeSigningConfig")) + + status, body = doSigned(t, ts, creator, create) + if status != http.StatusCreated { + t.Fatalf("CreateCodeSigningConfig: status %d %s, want 201", status, body) + } + + i := strings.Index(body, "arn:aws:lambda:") + if i < 0 { + t.Fatalf("no code signing config ARN in %s", body) + } + + arn := body[i : i+strings.Index(body[i:], `"`)] + + status, body = doSigned(t, ts, creator, sreq{method: http.MethodGet, path: "/2020-04-22/code-signing-configs/" + arn, service: "lambda"}) + wantDenied(t, status, body, lambdaDeny) + + if bucketExists(t, cloud, "2016-08-19") || bucketExists(t, cloud, "2020-04-22") { + t.Error("a Lambda request created an S3 bucket") + } +} + // TestAuthzMatrixLambdaUnknownOp checks a request Lambda answers with an // error is denied to a restricted caller and changes nothing for root. func TestAuthzMatrixLambdaUnknownOp(t *testing.T) { diff --git a/server/aws/lambda/account_settings.go b/server/aws/lambda/account_settings.go new file mode 100644 index 000000000..5df98d264 --- /dev/null +++ b/server/aws/lambda/account_settings.go @@ -0,0 +1,62 @@ +package lambda + +import ( + "context" + "net/http" + + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// accountSettingsPath is the GetAccountSettings route. See +// https://docs.aws.amazon.com/lambda/latest/api/API_GetAccountSettings.html +const accountSettingsPath = "/2016-08-19/account-settings" + +// accountSettingsReader is the AWS-only GetAccountSettings surface, type-asserted +// like the other AWS-only Lambda capabilities. +type accountSettingsReader interface { + GetAccountSettings(ctx context.Context) (*sdrv.AccountSettings, error) +} + +type accountLimitJSON struct { + TotalCodeSize int64 `json:"TotalCodeSize"` + CodeSizeUnzipped int64 `json:"CodeSizeUnzipped"` + CodeSizeZipped int64 `json:"CodeSizeZipped"` + ConcurrentExecutions int `json:"ConcurrentExecutions"` + UnreservedConcurrentExecutions int `json:"UnreservedConcurrentExecutions"` +} + +type accountUsageJSON struct { + TotalCodeSize int64 `json:"TotalCodeSize"` + FunctionCount int64 `json:"FunctionCount"` +} + +// serveAccountSettings handles GET /2016-08-19/account-settings. +func (h *Handler) serveAccountSettings(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + return + } + + reader, ok := h.fn.(accountSettingsReader) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "account settings not supported") + return + } + + s, err := reader.GetAccountSettings(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "AccountLimit": accountLimitJSON{ + TotalCodeSize: s.TotalCodeSizeLimit, + CodeSizeUnzipped: s.CodeSizeUnzipped, + CodeSizeZipped: s.CodeSizeZipped, + ConcurrentExecutions: s.ConcurrentExecutions, + UnreservedConcurrentExecutions: s.UnreservedConcurrentExecutions, + }, + "AccountUsage": accountUsageJSON{TotalCodeSize: s.TotalCodeSizeUsed, FunctionCount: s.FunctionCount}, + }) +} diff --git a/server/aws/lambda/classify.go b/server/aws/lambda/classify.go index 3f1e06d84..1e7a590c8 100644 --- a/server/aws/lambda/classify.go +++ b/server/aws/lambda/classify.go @@ -74,8 +74,18 @@ const ( opListProvisionedConcurrency opID = "ListProvisionedConcurrencyConfigs" opGetFunctionCodeSigningConfig opID = "GetFunctionCodeSigningConfig" + opPutFunctionCodeSigningConfig opID = "PutFunctionCodeSigningConfig" opDeleteFunctionCodeSigningConfig opID = "DeleteFunctionCodeSigningConfig" + opCreateCodeSigningConfig opID = "CreateCodeSigningConfig" + opListCodeSigningConfigs opID = "ListCodeSigningConfigs" + opGetCodeSigningConfig opID = "GetCodeSigningConfig" + opUpdateCodeSigningConfig opID = "UpdateCodeSigningConfig" + opDeleteCodeSigningConfig opID = "DeleteCodeSigningConfig" + opListFunctionsByCodeSigningConfig opID = "ListFunctionsByCodeSigningConfig" + + opGetAccountSettings opID = "GetAccountSettings" + opPutFunctionConcurrency opID = "PutFunctionConcurrency" opGetFunctionConcurrency opID = "GetFunctionConcurrency" opDeleteFunctionConcurrency opID = "DeleteFunctionConcurrency" @@ -96,6 +106,8 @@ const ( routeProvisionedConcurrency routeCodeSigning routeConcurrency + routeCodeSigningConfigs + routeAccountSettings ) // opArgs is what classify extracts from the request. @@ -188,6 +200,11 @@ func classify(r *http.Request) (opID, opArgs) { return classifyProvisionedConcurrency(r) case isCodeSigningPath(path): return classifyCodeSigning(r) + case strings.HasPrefix(path, codeSigningConfigsPrefix): + return classifyCodeSigningConfigs(r, trimRoute(path, codeSigningConfigsPrefix)) + case path == accountSettingsPath: + return methodOp(r.Method, map[string]opID{http.MethodGet: opGetAccountSettings}), + opArgs{route: routeAccountSettings} } if name, ok := concurrencyFunctionName(path); ok { @@ -389,7 +406,36 @@ func classifyCodeSigning(r *http.Request) (opID, opArgs) { a := opArgs{route: routeCodeSigning, name: name} return methodOp(r.Method, map[string]opID{ - http.MethodGet: opGetFunctionCodeSigningConfig, http.MethodDelete: opDeleteFunctionCodeSigningConfig, + http.MethodGet: opGetFunctionCodeSigningConfig, http.MethodPut: opPutFunctionCodeSigningConfig, + http.MethodDelete: opDeleteFunctionCodeSigningConfig, + }), a +} + +// classifyCodeSigningConfigs handles /2020-04-22/code-signing-configs[/{arn}[/functions]]. +// item is the (path-unescaped) code signing config ARN. +func classifyCodeSigningConfigs(r *http.Request, rest string) (opID, opArgs) { + a := opArgs{route: routeCodeSigningConfigs} + + if rest == "" { + return methodOp(r.Method, map[string]opID{ + http.MethodPost: opCreateCodeSigningConfig, http.MethodGet: opListCodeSigningConfigs, + }), a + } + + if arn, ok := strings.CutSuffix(rest, codeSigningFunctionsSuffix); ok && arn != "" { + a.item = arn + if r.Method == http.MethodGet { + return opListFunctionsByCodeSigningConfig, a + } + + return opUnknown, a + } + + a.item = rest + + return methodOp(r.Method, map[string]opID{ + http.MethodGet: opGetCodeSigningConfig, http.MethodPut: opUpdateCodeSigningConfig, + http.MethodDelete: opDeleteCodeSigningConfig, }), a } diff --git a/server/aws/lambda/classify_test.go b/server/aws/lambda/classify_test.go index 939706d8e..e21573c03 100644 --- a/server/aws/lambda/classify_test.go +++ b/server/aws/lambda/classify_test.go @@ -88,7 +88,14 @@ func TestClassify(t *testing.T) { }, {method: "GET", target: "/2020-06-30/functions/f/code-signing-config", op: opGetFunctionCodeSigningConfig, route: routeCodeSigning, name: "f"}, - {method: "PUT", target: "/2020-06-30/functions/f/code-signing-config", op: opUnknown, route: routeCodeSigning, name: "f"}, + {method: "PUT", target: "/2020-06-30/functions/f/code-signing-config", op: opPutFunctionCodeSigningConfig, route: routeCodeSigning, name: "f"}, + {method: "POST", target: "/2020-06-30/functions/f/code-signing-config", op: opUnknown, route: routeCodeSigning, name: "f"}, + + {method: "POST", target: "/2020-04-22/code-signing-configs", op: opCreateCodeSigningConfig, route: routeCodeSigningConfigs}, + {method: "GET", target: "/2020-04-22/code-signing-configs", op: opListCodeSigningConfigs, route: routeCodeSigningConfigs}, + {method: "GET", target: "/2020-04-22/code-signing-configs/arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-0123456789abcdef0", op: opGetCodeSigningConfig, route: routeCodeSigningConfigs, item: "arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-0123456789abcdef0"}, + {method: "GET", target: "/2020-04-22/code-signing-configs/arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-0123456789abcdef0/functions", op: opListFunctionsByCodeSigningConfig, route: routeCodeSigningConfigs, item: "arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-0123456789abcdef0"}, + {method: "GET", target: "/2016-08-19/account-settings", op: opGetAccountSettings, route: routeAccountSettings}, {method: "PUT", target: "/2017-10-31/functions/f/concurrency", op: opPutFunctionConcurrency, route: routeConcurrency, name: "f"}, {method: "GET", target: "/2017-10-31/functions/f/concurrency", op: opGetFunctionConcurrency, route: routeConcurrency, name: "f"}, diff --git a/server/aws/lambda/code_signing.go b/server/aws/lambda/code_signing.go index 9539e6fba..3facbc2bb 100644 --- a/server/aws/lambda/code_signing.go +++ b/server/aws/lambda/code_signing.go @@ -1,10 +1,86 @@ package lambda import ( + "context" "net/http" "strings" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + sdrv "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// codeSigningConfigsPrefix is the Lambda code signing config API prefix +// (Create/Get/List/Update/DeleteCodeSigningConfig and +// ListFunctionsByCodeSigningConfig at .../{arn}/functions). See +// https://docs.aws.amazon.com/lambda/latest/api/API_CreateCodeSigningConfig.html +const ( + codeSigningConfigsPrefix = "/2020-04-22/code-signing-configs" + codeSigningFunctionsSuffix = "/functions" ) +// codeSigningConfigNotFound is the error type PutFunctionCodeSigningConfig and +// CreateFunction return for an unknown code signing config ARN (HTTP 404). +const codeSigningConfigNotFound = "CodeSigningConfigNotFoundException" + +// codeSigningManager is the AWS-only code signing surface (code signing +// configs and their association with functions). It has no Azure Functions or +// GCP Cloud Functions equivalent, so the handler type-asserts for it rather than +// requiring every cloud's function provider to implement it. +type codeSigningManager interface { + CreateCodeSigningConfig(ctx context.Context, cfg sdrv.CodeSigningConfig) (*sdrv.CodeSigningConfig, error) + GetCodeSigningConfig(ctx context.Context, arn string) (*sdrv.CodeSigningConfig, error) + ListCodeSigningConfigs(ctx context.Context) ([]sdrv.CodeSigningConfig, error) + UpdateCodeSigningConfig(ctx context.Context, arn string, upd sdrv.CodeSigningConfigUpdate) (*sdrv.CodeSigningConfig, error) + DeleteCodeSigningConfig(ctx context.Context, arn string) error + PutFunctionCodeSigningConfig(ctx context.Context, name, arn string) error + GetFunctionCodeSigningConfig(ctx context.Context, name string) (string, error) + DeleteFunctionCodeSigningConfig(ctx context.Context, name string) error + ListFunctionsByCodeSigningConfig(ctx context.Context, arn string) ([]string, error) +} + +// codeSigningConfigEnvelope is the wire CodeSigningConfig shape. +type codeSigningConfigEnvelope struct { + CodeSigningConfigID string `json:"CodeSigningConfigId"` + CodeSigningConfigArn string `json:"CodeSigningConfigArn"` + Description string `json:"Description"` + AllowedPublishers allowedPublishers `json:"AllowedPublishers"` + CodeSigningPolicies codeSigningPoliciesJSON `json:"CodeSigningPolicies"` + LastModified string `json:"LastModified"` +} + +type allowedPublishers struct { + SigningProfileVersionArns []string `json:"SigningProfileVersionArns"` +} + +type codeSigningPoliciesJSON struct { + UntrustedArtifactOnDeployment string `json:"UntrustedArtifactOnDeployment,omitempty"` +} + +// codeSigningConfigRequest is the Create/UpdateCodeSigningConfig body. Fields +// are pointers so an Update can tell an omitted field from an empty one. +type codeSigningConfigRequest struct { + Description *string `json:"Description"` + AllowedPublishers *allowedPublishers `json:"AllowedPublishers"` + CodeSigningPolicies *codeSigningPoliciesJSON `json:"CodeSigningPolicies"` + Tags map[string]string `json:"Tags"` +} + +func toCodeSigningConfigEnvelope(c *sdrv.CodeSigningConfig) codeSigningConfigEnvelope { + arns := c.SigningProfileVersionARNs + if arns == nil { + arns = []string{} + } + + return codeSigningConfigEnvelope{ + CodeSigningConfigID: c.ID, + CodeSigningConfigArn: c.ARN, + Description: c.Description, + AllowedPublishers: allowedPublishers{SigningProfileVersionArns: arns}, + CodeSigningPolicies: codeSigningPoliciesJSON{UntrustedArtifactOnDeployment: c.UntrustedArtifactOnDeployment}, + LastModified: c.LastModified, + } +} + // isCodeSigningPath reports whether path is a function code-signing-config // sub-resource (/2020-06-30/functions/{name}/code-signing-config). func isCodeSigningPath(path string) bool { @@ -31,13 +107,11 @@ func codeSigningFunctionName(path string) (string, bool) { return name, true } -// serveFunctionCodeSigningConfig handles the code-signing-config sub-resource. -// cloudemu does not associate code-signing configs with functions, so a GET on -// an existing function returns 200 with an empty CodeSigningConfigArn (matching -// a function that has none). The function's existence is still validated so a -// missing function 404s. Terraform's function read calls this on every refresh; -// before this route existed the request fell through to the S3 catch-all and -// returned XML the REST-JSON client could not parse. +// serveFunctionCodeSigningConfig handles the function code-signing-config +// sub-resource: GET (GetFunctionCodeSigningConfig), PUT +// (PutFunctionCodeSigningConfig) and DELETE (DeleteFunctionCodeSigningConfig). +// A function with no config attached answers GET with 200 and an empty +// CodeSigningConfigArn; Terraform's function read calls it on every refresh. func (h *Handler) serveFunctionCodeSigningConfig(w http.ResponseWriter, r *http.Request, op opID, name string) { if name == "" { writeError(w, http.StatusNotFound, "ResourceNotFoundException", "unsupported Lambda path") @@ -49,16 +123,297 @@ func (h *Handler) serveFunctionCodeSigningConfig(w http.ResponseWriter, r *http. return } + csm, ok := h.fn.(codeSigningManager) + switch op { case opGetFunctionCodeSigningConfig: - writeJSON(w, http.StatusOK, map[string]any{ - "FunctionName": name, - "CodeSigningConfigArn": "", - }) + arn := "" + + if ok { + got, err := csm.GetFunctionCodeSigningConfig(r.Context(), name) + if err != nil { + writeErr(w, err) + return + } + + arn = got + } + + writeJSON(w, http.StatusOK, map[string]any{"FunctionName": name, "CodeSigningConfigArn": arn}) + case opPutFunctionCodeSigningConfig: + h.putFunctionCodeSigningConfig(w, r, csm, ok, name) case opDeleteFunctionCodeSigningConfig: - // No config is stored, so removal is a no-op that AWS answers 204. + if ok { + if err := csm.DeleteFunctionCodeSigningConfig(r.Context(), name); err != nil { + writeErr(w, err) + return + } + } + w.WriteHeader(http.StatusNoContent) default: writeError(w, http.StatusMethodNotAllowed, "MethodNotAllowed", "unsupported method for code-signing-config") } } + +// putFunctionCodeSigningConfig serves PutFunctionCodeSigningConfig. +func (h *Handler) putFunctionCodeSigningConfig( + w http.ResponseWriter, r *http.Request, csm codeSigningManager, ok bool, name string, +) { + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "code signing not supported") + return + } + + var req struct { + CodeSigningConfigArn string `json:"CodeSigningConfigArn"` + } + + if !decodeJSON(w, r, &req) { + return + } + + if !h.checkCodeSigningConfigExists(w, r, req.CodeSigningConfigArn) { + return + } + + if err := csm.PutFunctionCodeSigningConfig(r.Context(), name, req.CodeSigningConfigArn); err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]string{ + "FunctionName": name, "CodeSigningConfigArn": req.CodeSigningConfigArn, + }) +} + +// checkCodeSigningConfigExists writes CodeSigningConfigNotFoundException (404) +// and returns false when arn is set but names no code signing config, the error +// PutFunctionCodeSigningConfig and CreateFunction return for an unknown config +// (distinct from the function's ResourceNotFoundException). An empty arn passes. +func (h *Handler) checkCodeSigningConfigExists(w http.ResponseWriter, r *http.Request, arn string) bool { + if arn == "" { + return true + } + + csm, ok := h.fn.(codeSigningManager) + if !ok { + return true + } + + if _, err := csm.GetCodeSigningConfig(r.Context(), arn); err != nil { + if cerrors.IsNotFound(err) { + writeError(w, http.StatusNotFound, codeSigningConfigNotFound, cerrors.Message(err)) + } else { + writeErr(w, err) + } + + return false + } + + return true +} + +// serveCodeSigningConfigs handles /2020-04-22/code-signing-configs[/{arn}[/functions]]. +func (h *Handler) serveCodeSigningConfigs(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { + csm, ok := h.fn.(codeSigningManager) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "code signing not supported") + return + } + + switch op { + case opCreateCodeSigningConfig: + h.createCodeSigningConfig(w, r, csm) + case opListCodeSigningConfigs: + listCodeSigningConfigs(w, r, csm) + case opGetCodeSigningConfig: + c, err := csm.GetCodeSigningConfig(r.Context(), a.item) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"CodeSigningConfig": toCodeSigningConfigEnvelope(c)}) + case opUpdateCodeSigningConfig: + h.updateCodeSigningConfig(w, r, csm, a.item) + case opDeleteCodeSigningConfig: + if err := csm.DeleteCodeSigningConfig(r.Context(), a.item); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case opListFunctionsByCodeSigningConfig: + listFunctionsByCodeSigningConfig(w, r, csm, a.item) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} + +// listCodeSigningConfigs serves ListCodeSigningConfigs, paged by Marker/MaxItems. +func listCodeSigningConfigs(w http.ResponseWriter, r *http.Request, csm codeSigningManager) { + cfgs, err := csm.ListCodeSigningConfigs(r.Context()) + if err != nil { + writeErr(w, err) + return + } + + start, end, nextMarker, _ := pageWindow(len(cfgs), r.URL.Query()) + + out := make([]codeSigningConfigEnvelope, 0, end-start) + for i := start; i < end; i++ { + out = append(out, toCodeSigningConfigEnvelope(&cfgs[i])) + } + + resp := map[string]any{"CodeSigningConfigs": out} + if nextMarker != "" { + resp["NextMarker"] = nextMarker + } + + writeJSON(w, http.StatusOK, resp) +} + +// listFunctionsByCodeSigningConfig serves ListFunctionsByCodeSigningConfig, +// paged by Marker/MaxItems. +func listFunctionsByCodeSigningConfig(w http.ResponseWriter, r *http.Request, csm codeSigningManager, arn string) { + arns, err := csm.ListFunctionsByCodeSigningConfig(r.Context(), arn) + if err != nil { + writeErr(w, err) + return + } + + start, end, nextMarker, _ := pageWindow(len(arns), r.URL.Query()) + + resp := map[string]any{"FunctionArns": append([]string{}, arns[start:end]...)} + if nextMarker != "" { + resp["NextMarker"] = nextMarker + } + + writeJSON(w, http.StatusOK, resp) +} + +// createCodeSigningConfig serves CreateCodeSigningConfig (HTTP 201). +func (*Handler) createCodeSigningConfig(w http.ResponseWriter, r *http.Request, csm codeSigningManager) { + var req codeSigningConfigRequest + if !decodeJSON(w, r, &req) { + return + } + + cfg := sdrv.CodeSigningConfig{Tags: req.Tags} + if req.Description != nil { + cfg.Description = *req.Description + } + + if req.AllowedPublishers != nil { + cfg.SigningProfileVersionARNs = req.AllowedPublishers.SigningProfileVersionArns + } + + if req.CodeSigningPolicies != nil { + cfg.UntrustedArtifactOnDeployment = req.CodeSigningPolicies.UntrustedArtifactOnDeployment + } + + c, err := csm.CreateCodeSigningConfig(r.Context(), cfg) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusCreated, map[string]any{"CodeSigningConfig": toCodeSigningConfigEnvelope(c)}) +} + +// updateCodeSigningConfig serves UpdateCodeSigningConfig. +func (*Handler) updateCodeSigningConfig(w http.ResponseWriter, r *http.Request, csm codeSigningManager, arn string) { + var req codeSigningConfigRequest + if !decodeJSON(w, r, &req) { + return + } + + upd := sdrv.CodeSigningConfigUpdate{Description: req.Description} + if req.AllowedPublishers != nil { + upd.SigningProfileVersionARNs = req.AllowedPublishers.SigningProfileVersionArns + if upd.SigningProfileVersionARNs == nil { + upd.SigningProfileVersionARNs = []string{} + } + } + + if req.CodeSigningPolicies != nil && req.CodeSigningPolicies.UntrustedArtifactOnDeployment != "" { + policy := req.CodeSigningPolicies.UntrustedArtifactOnDeployment + upd.UntrustedArtifactOnDeployment = &policy + } + + c, err := csm.UpdateCodeSigningConfig(r.Context(), arn, upd) + if err != nil { + writeErr(w, err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"CodeSigningConfig": toCodeSigningConfigEnvelope(c)}) +} + +// codeSigningConfigARNMarker identifies a code signing config ARN +// (arn:aws:lambda:::code-signing-config:csc-...) on the +// tagging API, which Lambda shares between functions and code signing configs. +const codeSigningConfigARNMarker = ":code-signing-config:" + +// isCodeSigningConfigARN reports whether a tagging resource ARN names a code +// signing config rather than a function. +func isCodeSigningConfigARN(arn string) bool { + return strings.Contains(arn, codeSigningConfigARNMarker) +} + +// codeSigningTagger is the AWS-only tagging surface for code signing configs. +type codeSigningTagger interface { + TagCodeSigningConfig(ctx context.Context, arn string, tags map[string]string) error + UntagCodeSigningConfig(ctx context.Context, arn string, keys []string) error + ListCodeSigningConfigTags(ctx context.Context, arn string) (map[string]string, error) +} + +// serveCodeSigningConfigTags handles TagResource / UntagResource / ListTags on +// a code signing config ARN (/2017-03-31/tags/{arn}). +func (h *Handler) serveCodeSigningConfigTags(w http.ResponseWriter, r *http.Request, op opID, arn string) { + tagger, ok := h.fn.(codeSigningTagger) + if !ok { + writeError(w, http.StatusNotImplemented, "InvalidRequestException", "tagging not supported") + return + } + + switch op { + case opTagResource: + var req struct { + Tags map[string]string `json:"Tags"` + } + + if !decodeJSON(w, r, &req) { + return + } + + if err := tagger.TagCodeSigningConfig(r.Context(), arn, req.Tags); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case opUntagResource: + if err := tagger.UntagCodeSigningConfig(r.Context(), arn, r.URL.Query()["tagKeys"]); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) + case opListTags: + tags, err := tagger.ListCodeSigningConfigTags(r.Context(), arn) + if err != nil { + writeErr(w, err) + return + } + + if tags == nil { + tags = map[string]string{} + } + + writeJSON(w, http.StatusOK, map[string]any{"Tags": tags}) + default: + writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") + } +} diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index eabc1a1cc..5df4b19fd 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -17,6 +17,7 @@ package lambda import ( "context" + "encoding/base64" "encoding/json" "io" "net" @@ -119,14 +120,21 @@ const packageTypeZip = "Zip" // returns HTTP 202 with an empty body for it, versus 200 for RequestResponse. const invocationTypeEvent = "Event" +// logTypeTail is the X-Amz-Log-Type value asking Invoke to return the tail of +// the execution log in X-Amz-Log-Result. +const logTypeTail = "Tail" + +// statePending is the State of a function whose creation has not completed. +const statePending = "Pending" + // invocationTypeDryRun validates parameters and permissions without running the // function. AWS returns HTTP 204 No Content (nothing is executed). const invocationTypeDryRun = "DryRun" // lastUpdateStatusSuccessful is the terminal LastUpdateStatus real AWS reports -// once a create/update completes. cloudemu settles synchronously, so every -// config response carries it. This is the value the FunctionUpdatedV2 waiter -// (SAM/CDK/Terraform) polls for. +// once a create/update completes. It is the fallback for a backend that does +// not report LastUpdateStatus itself. This is the value the FunctionUpdatedV2 +// waiter (SAM/CDK/Terraform) polls for. const lastUpdateStatusSuccessful = "Successful" const ( @@ -207,6 +215,21 @@ type versionDeleter interface { DeleteVersion(ctx context.Context, name, qualifier string) error } +// versionAWSConfigReader is the AWS-only surface returning the AWS-only settings +// a published version froze when it was cut, so a version keeps reporting its +// own VpcConfig/Layers/Architectures/... after $LATEST changes. +type versionAWSConfigReader interface { + GetVersionAWSConfig(ctx context.Context, name, version string) (sdrv.AWSFunctionConfig, error) +} + +// writePublisher is the AWS-only surface backing Publish=true on +// CreateFunction/UpdateFunctionCode: AWS publishes atomically with that write, +// so the publish is not rejected by the Pending/InProgress state the write +// itself just started. +type writePublisher interface { + PublishVersionOnWrite(ctx context.Context, functionName, description string) (*sdrv.FunctionVersion, error) +} + // ObjectStore is the slice of the in-process S3 backend the handler needs to // fetch an S3-sourced deployment package (Code.S3Bucket/S3Key). type ObjectStore interface { @@ -265,6 +288,8 @@ func (*Handler) Matches(r *http.Request) bool { strings.HasPrefix(r.URL.Path, layersPrefix) || strings.HasPrefix(r.URL.Path, functionURLPrefix) || strings.HasPrefix(r.URL.Path, eventInvokeConfigPrefix) || + strings.HasPrefix(r.URL.Path, codeSigningConfigsPrefix) || + r.URL.Path == accountSettingsPath || isCodeSigningPath(r.URL.Path) } @@ -285,6 +310,11 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + if isCodeSigningConfigARN(a.item) { + h.serveCodeSigningConfigTags(w, r, op, a.item) + return + } + h.serveTags(w, r, op, a.name) case routeEventSourceMappings: h.serveEventSourceMappings(w, r, op, a.item) @@ -298,6 +328,10 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveProvisionedConcurrency(w, r, op, &a) case routeCodeSigning: h.serveFunctionCodeSigningConfig(w, r, op, a.name) + case routeCodeSigningConfigs: + h.serveCodeSigningConfigs(w, r, op, &a) + case routeAccountSettings: + h.serveAccountSettings(w, r) case routeConcurrency: h.serveConcurrency(w, r, op, a.name) case routeFunctions: @@ -790,7 +824,8 @@ func (h *Handler) serveVersions(w http.ResponseWriter, r *http.Request, op opID, return } - writeJSON(w, http.StatusCreated, toVersionConfiguration(info, ver, h.awsFnConfig(r.Context(), name))) + writeJSON(w, http.StatusCreated, toVersionConfiguration(info, ver, + h.versionAWSConfig(r.Context(), name, ver.Version, h.awsFnConfig(r.Context(), name)))) case opListVersionsByFunction: vers, err := h.fn.ListVersions(r.Context(), name) if err != nil { @@ -814,7 +849,8 @@ func (h *Handler) serveVersions(w http.ResponseWriter, r *http.Request, op opID, out := listVersionsResponse{Versions: make([]functionConfiguration, 0, end-start)} for i := start; i < end; i++ { - out.Versions = append(out.Versions, toVersionConfiguration(info, &vers[i], awsCfg)) + out.Versions = append(out.Versions, + toVersionConfiguration(info, &vers[i], h.versionAWSConfig(r.Context(), name, vers[i].Version, awsCfg))) } out.NextMarker = nextMarker @@ -999,6 +1035,10 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { return } + if !h.checkCodeSigningConfigExists(w, r, req.CodeSigningConfigArn) { + return + } + cfg := sdrv.FunctionConfig{ Name: req.FunctionName, Runtime: req.Runtime, @@ -1035,6 +1075,15 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { awsCfg := h.applyAWSConfig(r.Context(), req.FunctionName, h.createAWSConfig(&req), true) + if req.CodeSigningConfigArn != "" { + if csm, ok := h.fn.(codeSigningManager); ok { + if err := csm.PutFunctionCodeSigningConfig(r.Context(), req.FunctionName, req.CodeSigningConfigArn); err != nil { + writeErr(w, err) + return + } + } + } + // Publish=true cuts version 1 from the just-created function and returns that // published version's configuration (Version "1", :1-qualified ARN), matching // AWS and Terraform's aws_lambda_function{publish=true}. @@ -1142,12 +1191,36 @@ func (h *Handler) publishConfiguration( ctx context.Context, name, description string, info *sdrv.FunctionInfo, awsCfg *sdrv.AWSFunctionConfig, ) (functionConfiguration, error) { - ver, err := h.fn.PublishVersion(ctx, name, description) + publish := h.fn.PublishVersion + if wp, ok := h.fn.(writePublisher); ok { + publish = wp.PublishVersionOnWrite + } + + ver, err := publish(ctx, name, description) if err != nil { return functionConfiguration{}, err } - return toVersionConfiguration(info, ver, awsCfg), nil + return toVersionConfiguration(info, ver, h.versionAWSConfig(ctx, name, ver.Version, awsCfg)), nil +} + +// versionAWSConfig returns the AWS-only settings of one version of name: the +// settings a published version froze at publish time when the backend tracks +// them, else fallback (the function's live settings). +func (h *Handler) versionAWSConfig( + ctx context.Context, name, version string, fallback *sdrv.AWSFunctionConfig, +) *sdrv.AWSFunctionConfig { + r, ok := h.fn.(versionAWSConfigReader) + if !ok { + return fallback + } + + cfg, err := r.GetVersionAWSConfig(ctx, name, version) + if err != nil { + return fallback + } + + return &cfg } // validateLayers checks that every layer version ARN a CreateFunction / @@ -1278,7 +1351,7 @@ func (h *Handler) resolvedConfiguration(ctx context.Context, name, qualifier str return functionConfiguration{}, verr } - cfg := toVersionConfiguration(info, ver, awsCfg) + cfg := toVersionConfiguration(info, ver, h.versionAWSConfig(ctx, name, ver.Version, awsCfg)) cfg.FunctionArn = info.ARN + ":" + qualifier return cfg, nil @@ -1286,11 +1359,13 @@ func (h *Handler) resolvedConfiguration(ctx context.Context, name, qualifier str ver, verr := h.findVersion(ctx, name, qualifier) if verr != nil { + // Real Lambda names the qualified function ARN: "Function not found: + // arn:aws:lambda:::function::". return functionConfiguration{}, cerrors.Newf(cerrors.NotFound, - "function version or alias %q not found for %s", qualifier, name) + "Function not found: %s:%s", info.ARN, qualifier) } - return toVersionConfiguration(info, ver, awsCfg), nil + return toVersionConfiguration(info, ver, h.versionAWSConfig(ctx, name, ver.Version, awsCfg)), nil } // findVersion returns the published version (or $LATEST) snapshot matching @@ -1445,6 +1520,12 @@ func (h *Handler) invoke(w http.ResponseWriter, r *http.Request, name string) { w.Header().Set("X-Amz-Executed-Version", executedVersion) + // LogType=Tail returns "the last 4 KB of the execution log, which is + // base64-encoded" in X-Amz-Log-Result (synchronous invocations only). + if r.Header.Get("X-Amz-Log-Type") == logTypeTail && out.LogTail != "" { + w.Header().Set("X-Amz-Log-Result", base64.StdEncoding.EncodeToString([]byte(out.LogTail))) + } + if out.Error != "" { // Lambda surfaces handler errors via the X-Amz-Function-Error header // while still returning HTTP 200 with the error payload as the body. @@ -1483,6 +1564,12 @@ func toVersionConfiguration(info *sdrv.FunctionInfo, ver *sdrv.FunctionVersion, cfg.Role = ver.Role cfg.MemorySize = ver.Memory cfg.Timeout = ver.Timeout + cfg.CodeSize = ver.CodeSize + + cfg.Environment = nil + if len(ver.Environment) > 0 { + cfg.Environment = &envEnvelope{Variables: ver.Environment} + } // A published version's ARN is qualified with the version number; $LATEST // keeps the unqualified ARN. @@ -1510,7 +1597,9 @@ func toConfiguration(info *sdrv.FunctionInfo, awsCfg *sdrv.AWSFunctionConfig) fu Timeout: info.Timeout, LastModified: info.LastModified, State: info.State, - LastUpdateStatus: lastUpdateStatusSuccessful, + StateReason: info.StateReason, + StateReasonCode: info.StateReasonCode, + LastUpdateStatus: lastUpdateStatus(info), PackageType: packageTypeZip, CodeSha256: info.CodeSHA256, CodeSize: info.CodeSize, @@ -1567,6 +1656,22 @@ func applyAWSConfigToResponse(cfg *functionConfiguration, awsCfg *sdrv.AWSFuncti } } +// lastUpdateStatus is the LastUpdateStatus to report: the backend's own value +// (InProgress while an update settles, Successful after), nothing while a new +// function is still Pending (AWS first sets it once creation completes), and +// Successful for a backend that does not model the update lifecycle. +func lastUpdateStatus(info *sdrv.FunctionInfo) string { + if info.LastUpdateStatus != "" { + return info.LastUpdateStatus + } + + if info.State == statePending { + return "" + } + + return lastUpdateStatusSuccessful +} + // reservedConcurrency returns the function's reserved-concurrency envelope for // the GetFunction Concurrency field, or nil when no reserved concurrency has // been set (GetFunctionConcurrency reports NotFound). AWS also omits diff --git a/server/aws/lambda/iam_actions.go b/server/aws/lambda/iam_actions.go index ca0e86e79..544e7841e 100644 --- a/server/aws/lambda/iam_actions.go +++ b/server/aws/lambda/iam_actions.go @@ -147,6 +147,24 @@ func (c *checkSet) requestTags(tags map[string]string) { } // resourceTags sets aws:ResourceTag/* from the tags of the function name. +// codeSigningConfigTags sets the aws:ResourceTag/ condition keys from a code +// signing config's tags. +func (c *checkSet) codeSigningConfigTags(arn string) { + tagger, ok := c.h.fn.(codeSigningTagger) + if !ok { + return + } + + tags, err := tagger.ListCodeSigningConfigTags(c.r.Context(), arn) + if err != nil { + return + } + + for k, v := range tags { + c.cond[condResourceTagPrefix+k] = v + } +} + func (c *checkSet) resourceTags(name string) { tagger, ok := c.h.fn.(functionTagger) if !ok { @@ -298,13 +316,55 @@ var iamRules = map[opID]iamRule{ opListProvisionedConcurrency: onFunction, opGetFunctionCodeSigningConfig: onFunction, + opPutFunctionCodeSigningConfig: putFunctionCodeSigningChecks, opDeleteFunctionCodeSigningConfig: onFunction, + opCreateCodeSigningConfig: onAny, + opListCodeSigningConfigs: onAny, + opGetCodeSigningConfig: onCodeSigningConfig, + opUpdateCodeSigningConfig: onCodeSigningConfig, + opDeleteCodeSigningConfig: onCodeSigningConfig, + opListFunctionsByCodeSigningConfig: onCodeSigningConfig, + + opGetAccountSettings: onAny, + opPutFunctionConcurrency: onFunction, opGetFunctionConcurrency: onFunction, opDeleteFunctionConcurrency: onFunction, } +// onCodeSigningConfig: the operation's own action on the code signing config +// ARN the request names. +func onCodeSigningConfig(c *checkSet, op opID) bool { + c.add(action(op), c.args.item, awsauthz.Required) + c.codeSigningConfigTags(c.args.item) + + return true +} + +// putFunctionCodeSigningChecks: lambda:PutFunctionCodeSigningConfig on both the +// function and the code signing config it attaches (the Service Authorization +// Reference lists both resource types as required). +func putFunctionCodeSigningChecks(c *checkSet, op opID) bool { + var req struct { + CodeSigningConfigArn string `json:"CodeSigningConfigArn"` + } + + if !awsauthz.JSONBody(c.r, &req) { + return false + } + + c.add(action(op), c.unqualified(), awsauthz.Required) + + if req.CodeSigningConfigArn != "" { + c.add(action(op), req.CodeSigningConfigArn, awsauthz.Required) + } + + c.resourceTags(c.args.name) + + return true +} + // invokeChecks: lambda:InvokeFunction on the function, version or alias. The // function's resource-based policy can also grant it, so an implicit // identity deny is finished by the handler (authorizeInvoke). @@ -411,8 +471,14 @@ func tagChecks(c *checkSet, op opID) bool { resource = c.unqualified() } - c.add(action(op), resource, awsauthz.Required) - c.resourceTags(c.args.name) + // A code signing config ARN is its own resource, with its own tags. + if isCodeSigningConfigARN(c.args.item) { + c.add(action(op), c.args.item, awsauthz.Required) + c.codeSigningConfigTags(c.args.item) + } else { + c.add(action(op), resource, awsauthz.Required) + c.resourceTags(c.args.name) + } switch op { case opTagResource: diff --git a/server/aws/lambda/iam_actions_test.go b/server/aws/lambda/iam_actions_test.go index 76f1d9661..5ad6bb203 100644 --- a/server/aws/lambda/iam_actions_test.go +++ b/server/aws/lambda/iam_actions_test.go @@ -16,6 +16,7 @@ import ( const ( testFn = "arn:aws:lambda:us-east-1:123456789012:function:" + testCSC = "arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-0123456789abcdef0" testLayer = "arn:aws:lambda:us-east-1:123456789012:layer:" ) @@ -88,6 +89,17 @@ func TestIAMChecks(t *testing.T) { {"PUT", "/2019-09-30/functions/f/provisioned-concurrency?Qualifier=1", "{}", []string{"lambda:PutProvisionedConcurrencyConfig " + testFn + "f:1"}}, {"GET", "/2020-06-30/functions/f/code-signing-config", "", []string{"lambda:GetFunctionCodeSigningConfig " + testFn + "f"}}, + {"PUT", "/2020-06-30/functions/f/code-signing-config", `{"CodeSigningConfigArn":"arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-0123456789abcdef0"}`, + []string{"lambda:PutFunctionCodeSigningConfig " + testFn + "f", "lambda:PutFunctionCodeSigningConfig " + testCSC}}, + {"POST", "/2020-04-22/code-signing-configs", "{}", []string{"lambda:CreateCodeSigningConfig *"}}, + {"GET", "/2020-04-22/code-signing-configs", "", []string{"lambda:ListCodeSigningConfigs *"}}, + {"GET", "/2020-04-22/code-signing-configs/" + testCSC, "", []string{"lambda:GetCodeSigningConfig " + testCSC}}, + {"PUT", "/2020-04-22/code-signing-configs/" + testCSC, "{}", []string{"lambda:UpdateCodeSigningConfig " + testCSC}}, + {"DELETE", "/2020-04-22/code-signing-configs/" + testCSC, "", []string{"lambda:DeleteCodeSigningConfig " + testCSC}}, + {"GET", "/2020-04-22/code-signing-configs/" + testCSC + "/functions", "", + []string{"lambda:ListFunctionsByCodeSigningConfig " + testCSC}}, + {"GET", "/2017-03-31/tags/" + testCSC, "", []string{"lambda:ListTags " + testCSC}}, + {"GET", "/2016-08-19/account-settings", "", []string{"lambda:GetAccountSettings *"}}, {"PUT", "/2017-10-31/functions/f/concurrency", "{}", []string{"lambda:PutFunctionConcurrency " + testFn + "f"}}, } diff --git a/server/aws/lambda/parity_1518_sdk_test.go b/server/aws/lambda/parity_1518_sdk_test.go new file mode 100644 index 000000000..338ffbc81 --- /dev/null +++ b/server/aws/lambda/parity_1518_sdk_test.go @@ -0,0 +1,365 @@ +package lambda_test + +import ( + "context" + "encoding/base64" + "errors" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awsconfig "github.com/aws/aws-sdk-go-v2/config" + "github.com/aws/aws-sdk-go-v2/credentials" + awslambda "github.com/aws/aws-sdk-go-v2/service/lambda" + lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types" + cloudemu "github.com/stackshy/cloudemu/v2" + "github.com/stackshy/cloudemu/v2/config" + awsserver "github.com/stackshy/cloudemu/v2/server/aws" +) + +func newParityLambdaClient(t *testing.T, opts ...config.Option) *awslambda.Client { + t.Helper() + + cloud := cloudemu.NewAWS(opts...) + ts := httptest.NewServer(awsserver.New(awsserver.Drivers{Lambda: cloud.Lambda})) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + if err != nil { + t.Fatalf("aws config: %v", err) + } + + return awslambda.NewFromConfig(cfg, func(o *awslambda.Options) { o.BaseEndpoint = aws.String(ts.URL) }) +} + +func createParityFunction(t *testing.T, c *awslambda.Client, name string, code []byte) *awslambda.CreateFunctionOutput { + t.Helper() + + out, err := c.CreateFunction(context.Background(), &awslambda.CreateFunctionInput{ + FunctionName: aws.String(name), + Runtime: lambdatypes.RuntimePython312, + Role: aws.String("arn:aws:iam::123456789012:role/test"), + Handler: aws.String("main.handler"), + Code: &lambdatypes.FunctionCode{ZipFile: code}, + Environment: &lambdatypes.Environment{Variables: map[string]string{"STAGE": "one"}}, + }) + if err != nil { + t.Fatalf("CreateFunction: %v", err) + } + + return out +} + +// TestSDKPublishedVersionIsImmutable: after UpdateFunctionCode / +// UpdateFunctionConfiguration, GetFunction --qualifier 1 still reports the +// CodeSize, Environment and Architectures version 1 was cut with. +func TestSDKPublishedVersionIsImmutable(t *testing.T) { + c := newParityLambdaClient(t) + ctx := context.Background() + + createParityFunction(t, c, "imm", []byte("v1-code")) + + pub, err := c.PublishVersion(ctx, &awslambda.PublishVersionInput{FunctionName: aws.String("imm")}) + if err != nil { + t.Fatal(err) + } + + if pub.CodeSize != int64(len("v1-code")) { + t.Fatalf("PublishVersion CodeSize = %d", pub.CodeSize) + } + + if _, err := c.UpdateFunctionCode(ctx, &awslambda.UpdateFunctionCodeInput{ + FunctionName: aws.String("imm"), ZipFile: []byte("version-two-code-is-longer"), + Architectures: []lambdatypes.Architecture{lambdatypes.ArchitectureArm64}, + }); err != nil { + t.Fatal(err) + } + + if _, err := c.UpdateFunctionConfiguration(ctx, &awslambda.UpdateFunctionConfigurationInput{ + FunctionName: aws.String("imm"), + Environment: &lambdatypes.Environment{Variables: map[string]string{"STAGE": "two"}}, + }); err != nil { + t.Fatal(err) + } + + v1, err := c.GetFunction(ctx, &awslambda.GetFunctionInput{FunctionName: aws.String("imm"), Qualifier: aws.String("1")}) + if err != nil { + t.Fatal(err) + } + + cfg := v1.Configuration + if cfg.CodeSize != int64(len("v1-code")) { + t.Errorf("version 1 CodeSize = %d, want %d", cfg.CodeSize, len("v1-code")) + } + + if cfg.Environment == nil || cfg.Environment.Variables["STAGE"] != "one" { + t.Errorf("version 1 Environment = %+v, want STAGE=one", cfg.Environment) + } + + if len(cfg.Architectures) != 1 || cfg.Architectures[0] != lambdatypes.ArchitectureX8664 { + t.Errorf("version 1 Architectures = %v, want [x86_64]", cfg.Architectures) + } + + latest, err := c.GetFunction(ctx, &awslambda.GetFunctionInput{FunctionName: aws.String("imm")}) + if err != nil { + t.Fatal(err) + } + + if latest.Configuration.CodeSize != int64(len("version-two-code-is-longer")) { + t.Errorf("$LATEST CodeSize = %d", latest.Configuration.CodeSize) + } + + vers, err := c.ListVersionsByFunction(ctx, &awslambda.ListVersionsByFunctionInput{FunctionName: aws.String("imm")}) + if err != nil { + t.Fatal(err) + } + + for _, v := range vers.Versions { + if aws.ToString(v.Version) == "1" && v.CodeSize != int64(len("v1-code")) { + t.Errorf("ListVersions version 1 CodeSize = %d", v.CodeSize) + } + } +} + +// TestSDKInvokeLogTypeTail: LogType=Tail returns the base64 execution-log tail. +func TestSDKInvokeLogTypeTail(t *testing.T) { + c := newParityLambdaClient(t) + ctx := context.Background() + + createParityFunction(t, c, "logs", []byte("zip")) + + out, err := c.Invoke(ctx, &awslambda.InvokeInput{ + FunctionName: aws.String("logs"), Payload: []byte(`{"a":1}`), LogType: lambdatypes.LogTypeTail, + }) + if err != nil { + t.Fatal(err) + } + + raw, err := base64.StdEncoding.DecodeString(aws.ToString(out.LogResult)) + if err != nil { + t.Fatalf("LogResult not base64: %v", err) + } + + for _, want := range []string{"START RequestId:", "END RequestId:", "REPORT RequestId:"} { + if !strings.Contains(string(raw), want) { + t.Errorf("log tail missing %q: %q", want, raw) + } + } + + plain, err := c.Invoke(ctx, &awslambda.InvokeInput{FunctionName: aws.String("logs")}) + if err != nil { + t.Fatal(err) + } + + if plain.LogResult != nil { + t.Errorf("LogType=None LogResult = %q, want nil", aws.ToString(plain.LogResult)) + } +} + +// TestSDKGetAccountSettings drives GET /2016-08-19/account-settings. +func TestSDKGetAccountSettings(t *testing.T) { + c := newParityLambdaClient(t) + ctx := context.Background() + + createParityFunction(t, c, "acct", []byte("1234567")) + + out, err := c.GetAccountSettings(ctx, &awslambda.GetAccountSettingsInput{}) + if err != nil { + t.Fatal(err) + } + + if out.AccountLimit.ConcurrentExecutions != 1000 || + aws.ToInt32(out.AccountLimit.UnreservedConcurrentExecutions) != 1000 { + t.Errorf("limits = %+v", out.AccountLimit) + } + + if out.AccountUsage.FunctionCount != 1 || out.AccountUsage.TotalCodeSize != 7 { + t.Errorf("usage = %+v", out.AccountUsage) + } +} + +// TestSDKCodeSigningConfigs drives the code signing config API end to end. +func TestSDKCodeSigningConfigs(t *testing.T) { + c := newParityLambdaClient(t) + ctx := context.Background() + + created, err := c.CreateCodeSigningConfig(ctx, &awslambda.CreateCodeSigningConfigInput{ + Description: aws.String("demo"), + Tags: map[string]string{"team": "a"}, + AllowedPublishers: &lambdatypes.AllowedPublishers{ + SigningProfileVersionArns: []string{"arn:aws:signer:us-east-1:123456789012:/signing-profiles/p/abc"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + csc := created.CodeSigningConfig + arn := aws.ToString(csc.CodeSigningConfigArn) + + if csc.CodeSigningPolicies.UntrustedArtifactOnDeployment != lambdatypes.CodeSigningPolicyWarn { + t.Errorf("default policy = %q", csc.CodeSigningPolicies.UntrustedArtifactOnDeployment) + } + + got, err := c.GetCodeSigningConfig(ctx, &awslambda.GetCodeSigningConfigInput{CodeSigningConfigArn: aws.String(arn)}) + if err != nil || aws.ToString(got.CodeSigningConfig.Description) != "demo" { + t.Fatalf("get = %+v, %v", got, err) + } + + if _, err := c.UpdateCodeSigningConfig(ctx, &awslambda.UpdateCodeSigningConfigInput{ + CodeSigningConfigArn: aws.String(arn), + CodeSigningPolicies: &lambdatypes.CodeSigningPolicies{ + UntrustedArtifactOnDeployment: lambdatypes.CodeSigningPolicyEnforce, + }, + }); err != nil { + t.Fatal(err) + } + + list, err := c.ListCodeSigningConfigs(ctx, &awslambda.ListCodeSigningConfigsInput{}) + if err != nil || len(list.CodeSigningConfigs) != 1 || + list.CodeSigningConfigs[0].CodeSigningPolicies.UntrustedArtifactOnDeployment != lambdatypes.CodeSigningPolicyEnforce { + t.Fatalf("list = %+v, %v", list, err) + } + + // Tags given on create are visible through the shared tagging API. + if _, err := c.TagResource(ctx, &awslambda.TagResourceInput{ + Resource: aws.String(arn), Tags: map[string]string{"env": "dev"}, + }); err != nil { + t.Fatal(err) + } + + if _, err := c.UntagResource(ctx, &awslambda.UntagResourceInput{ + Resource: aws.String(arn), TagKeys: []string{"team"}, + }); err != nil { + t.Fatal(err) + } + + tags, err := c.ListTags(ctx, &awslambda.ListTagsInput{Resource: aws.String(arn)}) + if err != nil || len(tags.Tags) != 1 || tags.Tags["env"] != "dev" { + t.Fatalf("csc tags = %v, %v; want {env:dev}", tags, err) + } + + createParityFunction(t, c, "signed", []byte("zip")) + + missing := "arn:aws:lambda:us-east-1:123456789012:code-signing-config:csc-00000000000000000" + + _, err = c.PutFunctionCodeSigningConfig(ctx, &awslambda.PutFunctionCodeSigningConfigInput{ + FunctionName: aws.String("signed"), CodeSigningConfigArn: aws.String(missing), + }) + + var notFound *lambdatypes.CodeSigningConfigNotFoundException + if !errors.As(err, ¬Found) { + t.Fatalf("put unknown csc err = %v, want CodeSigningConfigNotFoundException", err) + } + + if _, err := c.PutFunctionCodeSigningConfig(ctx, &awslambda.PutFunctionCodeSigningConfigInput{ + FunctionName: aws.String("signed"), CodeSigningConfigArn: aws.String(arn), + }); err != nil { + t.Fatal(err) + } + + fcsc, err := c.GetFunctionCodeSigningConfig(ctx, &awslambda.GetFunctionCodeSigningConfigInput{FunctionName: aws.String("signed")}) + if err != nil || aws.ToString(fcsc.CodeSigningConfigArn) != arn { + t.Fatalf("get function csc = %+v, %v", fcsc, err) + } + + fns, err := c.ListFunctionsByCodeSigningConfig(ctx, &awslambda.ListFunctionsByCodeSigningConfigInput{ + CodeSigningConfigArn: aws.String(arn), + }) + if err != nil || len(fns.FunctionArns) != 1 { + t.Fatalf("list functions = %+v, %v", fns, err) + } + + _, err = c.DeleteCodeSigningConfig(ctx, &awslambda.DeleteCodeSigningConfigInput{CodeSigningConfigArn: aws.String(arn)}) + + var conflict *lambdatypes.ResourceConflictException + if !errors.As(err, &conflict) { + t.Fatalf("delete in-use err = %v, want ResourceConflictException", err) + } + + if _, err := c.DeleteFunctionCodeSigningConfig(ctx, &awslambda.DeleteFunctionCodeSigningConfigInput{ + FunctionName: aws.String("signed"), + }); err != nil { + t.Fatal(err) + } + + if _, err := c.DeleteCodeSigningConfig(ctx, &awslambda.DeleteCodeSigningConfigInput{ + CodeSigningConfigArn: aws.String(arn), + }); err != nil { + t.Fatal(err) + } + + _, err = c.GetCodeSigningConfig(ctx, &awslambda.GetCodeSigningConfigInput{CodeSigningConfigArn: aws.String(arn)}) + + var rnf *lambdatypes.ResourceNotFoundException + if !errors.As(err, &rnf) { + t.Fatalf("get deleted err = %v, want ResourceNotFoundException", err) + } +} + +// TestSDKFunctionNotFoundMessage: the ResourceNotFoundException names the ARN. +func TestSDKFunctionNotFoundMessage(t *testing.T) { + c := newParityLambdaClient(t) + + _, err := c.GetFunction(context.Background(), &awslambda.GetFunctionInput{FunctionName: aws.String("ghost")}) + + var rnf *lambdatypes.ResourceNotFoundException + if !errors.As(err, &rnf) { + t.Fatalf("err = %v", err) + } + + if want := "Function not found: arn:aws:lambda:us-east-1:123456789012:function:ghost"; aws.ToString(rnf.Message) != want { + t.Errorf("message = %q, want %q", aws.ToString(rnf.Message), want) + } +} + +// TestSDKAsyncSettleWaiters: under AsyncSettle the SDK FunctionActiveV2 and +// FunctionUpdatedV2 waiters observe Pending -> Active and InProgress -> +// Successful, and an Invoke while Pending is a ResourceConflictException. +func TestSDKAsyncSettleWaiters(t *testing.T) { + c := newParityLambdaClient(t, config.WithAsyncSettle()) + ctx := context.Background() + + created := createParityFunction(t, c, "settle", []byte("zip")) + if created.State != lambdatypes.StatePending || created.StateReasonCode != lambdatypes.StateReasonCodeCreating || + created.LastUpdateStatus != "" { + t.Fatalf("create = %q/%q/%q", created.State, created.StateReasonCode, created.LastUpdateStatus) + } + + _, err := c.Invoke(ctx, &awslambda.InvokeInput{FunctionName: aws.String("settle")}) + + var conflict *lambdatypes.ResourceConflictException + if !errors.As(err, &conflict) { + t.Fatalf("invoke while Pending err = %v, want ResourceConflictException", err) + } + + waitActive := awslambda.NewFunctionActiveV2Waiter(c, func(o *awslambda.FunctionActiveV2WaiterOptions) { + o.MinDelay, o.MaxDelay = 200*time.Millisecond, 300*time.Millisecond + }) + if err := waitActive.Wait(ctx, &awslambda.GetFunctionInput{FunctionName: aws.String("settle")}, 10*time.Second); err != nil { + t.Fatalf("FunctionActiveV2: %v", err) + } + + upd, err := c.UpdateFunctionConfiguration(ctx, &awslambda.UpdateFunctionConfigurationInput{ + FunctionName: aws.String("settle"), Timeout: aws.Int32(9), + }) + if err != nil { + t.Fatal(err) + } + + if upd.LastUpdateStatus != lambdatypes.LastUpdateStatusInProgress || upd.State != lambdatypes.StateActive { + t.Fatalf("update = %q/%q", upd.State, upd.LastUpdateStatus) + } + + waitUpdated := awslambda.NewFunctionUpdatedV2Waiter(c, func(o *awslambda.FunctionUpdatedV2WaiterOptions) { + o.MinDelay, o.MaxDelay = 200*time.Millisecond, 300*time.Millisecond + }) + if err := waitUpdated.Wait(ctx, &awslambda.GetFunctionInput{FunctionName: aws.String("settle")}, 10*time.Second); err != nil { + t.Fatalf("FunctionUpdatedV2: %v", err) + } +} diff --git a/server/aws/lambda/types.go b/server/aws/lambda/types.go index 67ccc419c..bdc6c4e5d 100644 --- a/server/aws/lambda/types.go +++ b/server/aws/lambda/types.go @@ -52,6 +52,10 @@ type functionConfiguration struct { Timeout int `json:"Timeout,omitempty"` LastModified string `json:"LastModified,omitempty"` State string `json:"State,omitempty"` + // StateReason / StateReasonCode explain a Pending function ("The function + // is being created." / "Creating"); omitted once it is Active. + StateReason string `json:"StateReason,omitempty"` + StateReasonCode string `json:"StateReasonCode,omitempty"` // LastUpdateStatus is the terminal status of the last create/update ("Successful"). // SDK waiters (FunctionUpdatedV2) poll GetFunctionConfiguration for it. LastUpdateStatus string `json:"LastUpdateStatus,omitempty"` @@ -227,6 +231,8 @@ type createFunctionRequest struct { // Publish, when true, publishes version 1 from the created function and // returns that version's configuration (Version "1", qualified ARN). Publish bool `json:"Publish"` + // CodeSigningConfigArn attaches a code signing config to the new function. + CodeSigningConfigArn string `json:"CodeSigningConfigArn"` } // functionCode is the deployment package in a CreateFunction body. The AWS SDK diff --git a/services/serverless/driver/driver.go b/services/serverless/driver/driver.go index b12e7f299..9d6f09e9d 100644 --- a/services/serverless/driver/driver.go +++ b/services/serverless/driver/driver.go @@ -20,6 +20,12 @@ type FunctionVersion struct { Memory int Timeout int Role string + // CodeSize is the deployment-package size (bytes) the version was cut + // from. Like CodeSHA256 it is frozen at publish time, so a later + // UpdateFunctionCode on $LATEST does not change a published version's size. + CodeSize int64 + // Environment is the environment-variable set snapshotted at publish time. + Environment map[string]string } // PermissionStatement is one statement of a function's resource-based policy, @@ -335,6 +341,17 @@ type FunctionInfo struct { Version string // RevisionID changes on every configuration or code update. RevisionID string + // StateReason / StateReasonCode explain a non-terminal State (AWS Lambda + // reports "The function is being created." / "Creating" while a new + // function is Pending). Empty when the function is in its terminal state. + StateReason string + StateReasonCode string + // LastUpdateStatus is the status of the last create/update: "InProgress" + // while an update settles, "Successful" once it completes, and empty while + // a freshly created function is still Pending (AWS first sets it to + // Successful when creation completes). A backend that does not model the + // update lifecycle leaves it empty. + LastUpdateStatus string } // InvokeInput configures a function invocation. @@ -363,6 +380,12 @@ type InvokeOutput struct { // service (CloudWatch Logs / Cloud Logging / Log Analytics) rather than // returned on the wire. Logs string + // LogTail is the tail (at most the last 4 KB) of the invocation's + // execution log, i.e. the START/END/REPORT lines plus any captured output + // in plain text. AWS returns it base64-encoded in X-Amz-Log-Result when the + // caller asks for LogType=Tail. Empty when the backend does not render an + // execution log. + LogTail string } // HandlerFunc is a function handler that processes invocations. @@ -432,3 +455,44 @@ type Serverless interface { ListEventSourceMappings(ctx context.Context, functionName string) ([]EventSourceMappingInfo, error) UpdateEventSourceMapping(ctx context.Context, uuid string, config EventSourceMappingConfig) (*EventSourceMappingInfo, error) } + +// CodeSigningConfig is an AWS Lambda code signing configuration +// (CreateCodeSigningConfig): the signing profiles trusted to sign a function's +// deployment package and the action taken when validation fails. It has no +// Azure Functions or GCP Cloud Functions equivalent, so, like +// EventInvokeConfig, it is managed through an AWS-only optional interface +// rather than the portable Serverless interface. +type CodeSigningConfig struct { + // ID is the "csc-" prefixed identifier; ARN embeds it. + ID string + ARN string + Description string + // SigningProfileVersionARNs is AllowedPublishers.SigningProfileVersionArns. + SigningProfileVersionARNs []string + // UntrustedArtifactOnDeployment is the CodeSigningPolicies action, "Warn" + // (the AWS default) or "Enforce". + UntrustedArtifactOnDeployment string + // LastModified is the ISO 8601 timestamp of the last create/update. + LastModified string + Tags map[string]string +} + +// CodeSigningConfigUpdate carries the optional fields of an +// UpdateCodeSigningConfig request; a nil field is left unchanged. +type CodeSigningConfigUpdate struct { + Description *string + SigningProfileVersionARNs []string + UntrustedArtifactOnDeployment *string +} + +// AccountSettings is the AWS Lambda GetAccountSettings result: the account's +// limits and current usage in a Region. All sizes are in bytes. +type AccountSettings struct { + TotalCodeSizeLimit int64 + CodeSizeUnzipped int64 + CodeSizeZipped int64 + ConcurrentExecutions int + UnreservedConcurrentExecutions int + FunctionCount int64 + TotalCodeSizeUsed int64 +} From 3c16d4104b0d778b0cb04642bea4e702bd022555 Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Thu, 22 Oct 2026 11:49:12 +0530 Subject: [PATCH 2/7] fix(aws-lambda): keep a published version's own lifecycle; roll back CreateFunction on code signing attach failure - A published version no longer inherits $LATEST's State, StateReason(Code), LastUpdateStatus and LastModified. The provider now reports each version's own lifecycle on FunctionVersion (Active/Successful, or Pending only for the version CreateFunction(Publish=true) cut, until creation completes), and the handler renders LastModified from the version's publish time. GetFunction --qualifier N and ListVersionsByFunction are unchanged by a later $LATEST update, in default mode and under --async-settle. - CreateFunction with CodeSigningConfigArn deletes the just-created function when the attach fails (config deleted after the existence check) and reports CodeSigningConfigNotFoundException, so a retry does not hit ResourceConflictException. PutFunctionCodeSigningConfig maps the same race to CodeSigningConfigNotFoundException. - GetAlias for a missing alias returns real Lambda's text: "Cannot find alias arn: :". - Move resourceTags' doc comment back above its func. --- providers/aws/lambda/aliases.go | 3 +- providers/aws/lambda/lambda.go | 4 + providers/aws/lambda/parity_1518_test.go | 70 ++++++ providers/aws/lambda/versions.go | 41 +++- server/aws/lambda/code_signing.go | 8 +- server/aws/lambda/handler.go | 78 ++++++- server/aws/lambda/iam_actions.go | 2 +- server/aws/lambda/parity_1518_sdk_test.go | 269 ++++++++++++++++++++++ services/serverless/driver/driver.go | 10 + 9 files changed, 464 insertions(+), 21 deletions(-) diff --git a/providers/aws/lambda/aliases.go b/providers/aws/lambda/aliases.go index db67d14e4..50458f14e 100644 --- a/providers/aws/lambda/aliases.go +++ b/providers/aws/lambda/aliases.go @@ -174,7 +174,8 @@ func (m *Mock) GetAlias(ctx context.Context, functionName, aliasName string) (*d ad, ok := fd.aliases.Get(aliasName) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "alias %s not found", aliasName) + // Real Lambda's GetAlias text names the alias-qualified function ARN. + return nil, cerrors.Newf(cerrors.NotFound, "Cannot find alias arn: %s:%s", fd.info.ARN, aliasName) } ad.mu.Lock() diff --git a/providers/aws/lambda/lambda.go b/providers/aws/lambda/lambda.go index 327def3d5..5270e631e 100644 --- a/providers/aws/lambda/lambda.go +++ b/providers/aws/lambda/lambda.go @@ -197,6 +197,10 @@ type versionData struct { // awsConfig freezes the AWS-only settings (VpcConfig, DeadLetterConfig, // TracingConfig, Architectures, EphemeralStorage, Layers) at publish time. awsConfig driver.AWSFunctionConfig + // cutWhilePending marks the version CreateFunction(Publish=true) cut while + // the function was still Pending creation; it reports Pending until creation + // completes. Every other published version is Active/Successful. + cutWhilePending bool } type aliasData struct { diff --git a/providers/aws/lambda/parity_1518_test.go b/providers/aws/lambda/parity_1518_test.go index 9eeff760b..3e005ad92 100644 --- a/providers/aws/lambda/parity_1518_test.go +++ b/providers/aws/lambda/parity_1518_test.go @@ -429,3 +429,73 @@ func TestFunctionNotFoundMessageNamesARN(t *testing.T) { t.Errorf("Invoke qualifier message = %q", got) } } + +// TestGetAliasNotFoundMessage matches real Lambda's GetAlias text for a missing +// alias: "Cannot find alias arn: :". +func TestGetAliasNotFoundMessage(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + _, err := m.GetAlias(ctx, "my-func", "current") + if !cerrors.IsNotFound(err) { + t.Fatalf("err = %v, want NotFound", err) + } + + if got, want := cerrors.Message(err), + "Cannot find alias arn: arn:aws:lambda:us-east-1:123456789012:function:my-func:current"; got != want { + t.Errorf("message = %q, want %q", got, want) + } +} + +// TestPublishedVersionReportsItsOwnLifecycle: a published version reports its +// own State/LastUpdateStatus (Active/Successful), never $LATEST's in-flight +// update; only the version CreateFunction(Publish=true) cuts is Pending, and +// only until creation completes. +func TestPublishedVersionReportsItsOwnLifecycle(t *testing.T) { + ctx := context.Background() + m, fc := newSettleMock() + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + onCreate, err := m.PublishVersionOnWrite(ctx, "my-func", "") + if err != nil { + t.Fatal(err) + } + + if onCreate.State != statePending || onCreate.StateReasonCode != stateReasonCodeCreating || onCreate.LastUpdateStatus != "" { + t.Fatalf("version cut on create = %q/%q/%q, want Pending/Creating/", + onCreate.State, onCreate.StateReasonCode, onCreate.LastUpdateStatus) + } + + fc.Advance(2 * time.Second) + + if _, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Timeout: 10}); err != nil { + t.Fatal(err) + } + + vers, err := m.ListVersions(ctx, "my-func") + if err != nil { + t.Fatal(err) + } + + for _, v := range vers { + if v.Version != "1" { + continue + } + + if v.State != stateActive || v.StateReasonCode != "" || v.LastUpdateStatus != lastUpdateSuccessful { + t.Errorf("version 1 during $LATEST update = %q/%q/%q, want Active//Successful", + v.State, v.StateReasonCode, v.LastUpdateStatus) + } + + if v.CreatedAt != "2025-01-01T00:00:00Z" { + t.Errorf("version 1 CreatedAt = %q, want its publish time", v.CreatedAt) + } + } +} diff --git a/providers/aws/lambda/versions.go b/providers/aws/lambda/versions.go index 8408afe17..5eee0ad5d 100644 --- a/providers/aws/lambda/versions.go +++ b/providers/aws/lambda/versions.go @@ -64,7 +64,7 @@ func (m *Mock) publishVersion( // RevisionID, so a last-published version cut from the current $LATEST revision // means nothing changed and no new version is created. if n := len(fd.versions); n > 0 && fd.versions[n-1].revisionID == fd.info.RevisionID { - return versionResult(functionName, fd.versions[n-1], description), nil + return m.versionResult(functionName, fd.versions[n-1], description), nil } verNum := fd.nextVersion @@ -86,19 +86,22 @@ func (m *Mock) publishVersion( createdAt: m.opts.Clock.Now().UTC().Format(time.RFC3339), description: verDesc, awsConfig: cloneAWSConfig(&fd.awsConfig), + // Only the atomic Publish=true on CreateFunction can cut a version while + // the function is still Pending; a standalone PublishVersion is refused then. + cutWhilePending: !gate && m.isPending(functionName), } fd.versions = append(fd.versions, vd) m.funcs.Set(functionName, fd) - return versionResult(functionName, vd, ""), nil + return m.versionResult(functionName, vd, ""), nil } // versionResult renders a published version's driver.FunctionVersion, used both // when a fresh version is cut and when a no-change PublishVersion returns the // existing version. A non-empty description overrides the stored version // description, matching PublishVersion's Description parameter. -func versionResult(functionName string, v *versionData, description string) *driver.FunctionVersion { - out := versionInfo(functionName, v) +func (m *Mock) versionResult(functionName string, v *versionData, description string) *driver.FunctionVersion { + out := m.versionInfo(functionName, v) if description != "" { out.Description = description } @@ -107,9 +110,9 @@ func versionResult(functionName string, v *versionData, description string) *dri } // versionInfo renders a published version from its frozen snapshot only, never -// from the mutable $LATEST state. -func versionInfo(functionName string, v *versionData) driver.FunctionVersion { - return driver.FunctionVersion{ +// from the mutable $LATEST state, with the version's own lifecycle. +func (m *Mock) versionInfo(functionName string, v *versionData) driver.FunctionVersion { + out := driver.FunctionVersion{ FunctionName: functionName, Version: v.version, Description: v.description, @@ -124,6 +127,28 @@ func versionInfo(functionName string, v *versionData) driver.FunctionVersion { Role: v.config.Role, Environment: maps.Clone(v.config.Environment), } + + m.observeVersion(functionName, v, &out) + + return out +} + +// observeVersion sets a published version's lifecycle. A published version is +// immutable, so an update of $LATEST (and its InProgress window) never reaches +// it: it is Active with LastUpdateStatus Successful. The one exception is the +// version CreateFunction(Publish=true) cut, which AWS reports Pending (reason +// Creating, no LastUpdateStatus) until the function's creation completes. +func (m *Mock) observeVersion(functionName string, v *versionData, out *driver.FunctionVersion) { + if v.cutWhilePending && m.isPending(functionName) { + out.State = statePending + out.StateReason = stateReasonCreating + out.StateReasonCode = stateReasonCodeCreating + + return + } + + out.State = stateActive + out.LastUpdateStatus = lastUpdateSuccessful } // ListVersions returns all published versions for a function. @@ -151,7 +176,7 @@ func (m *Mock) ListVersions(ctx context.Context, functionName string) ([]driver. }) for _, v := range fd.versions { - result = append(result, versionInfo(functionName, v)) + result = append(result, m.versionInfo(functionName, v)) } return result, nil diff --git a/server/aws/lambda/code_signing.go b/server/aws/lambda/code_signing.go index 3facbc2bb..b88a70a04 100644 --- a/server/aws/lambda/code_signing.go +++ b/server/aws/lambda/code_signing.go @@ -178,7 +178,13 @@ func (h *Handler) putFunctionCodeSigningConfig( } if err := csm.PutFunctionCodeSigningConfig(r.Context(), name, req.CodeSigningConfigArn); err != nil { - writeErr(w, err) + // A config deleted since the check above is still the config's + // CodeSigningConfigNotFoundException, not the function's + // ResourceNotFoundException. + if h.checkCodeSigningConfigExists(w, r, req.CodeSigningConfigArn) { + writeErr(w, err) + } + return } diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 5df4b19fd..a74b94ea1 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -127,6 +127,11 @@ const logTypeTail = "Tail" // statePending is the State of a function whose creation has not completed. const statePending = "Pending" +// stateActive is the State of a function (or published version) ready to be +// invoked; the fallback for a published version whose backend does not report +// the version's own lifecycle. +const stateActive = "Active" + // invocationTypeDryRun validates parameters and permissions without running the // function. AWS returns HTTP 204 No Content (nothing is executed). const invocationTypeDryRun = "DryRun" @@ -1075,13 +1080,8 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { awsCfg := h.applyAWSConfig(r.Context(), req.FunctionName, h.createAWSConfig(&req), true) - if req.CodeSigningConfigArn != "" { - if csm, ok := h.fn.(codeSigningManager); ok { - if err := csm.PutFunctionCodeSigningConfig(r.Context(), req.FunctionName, req.CodeSigningConfigArn); err != nil { - writeErr(w, err) - return - } - } + if !h.attachCreateCodeSigningConfig(w, r, req.FunctionName, req.CodeSigningConfigArn) { + return } // Publish=true cuts version 1 from the just-created function and returns that @@ -1102,6 +1102,39 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { writeJSON(w, http.StatusCreated, toConfiguration(info, awsCfg)) } +// attachCreateCodeSigningConfig attaches the CodeSigningConfigArn of a +// CreateFunction request to the just-created function. AWS validates the config +// before creating anything, so when the attach fails (the config was deleted +// after checkCodeSigningConfigExists) the function is rolled back rather than +// left behind for a retry to conflict with, and the error is reported as the +// pre-check would have reported it. It writes the error and returns false on +// failure; an empty arn or a backend without code signing passes. +func (h *Handler) attachCreateCodeSigningConfig(w http.ResponseWriter, r *http.Request, name, arn string) bool { + if arn == "" { + return true + } + + csm, ok := h.fn.(codeSigningManager) + if !ok { + return true + } + + err := csm.PutFunctionCodeSigningConfig(r.Context(), name, arn) + if err == nil { + return true + } + + // Best effort: the function was created by this request and nothing has been + // returned to the client yet, so removing it restores the pre-request state. + _ = h.fn.DeleteFunction(r.Context(), name) + + if h.checkCodeSigningConfigExists(w, r, arn) { + writeErr(w, err) + } + + return false +} + // checkCreateRequest resolves the FunctionName of a CreateFunction request to // the bare name it creates (createFunctionName, the same rule IAMChecks // authorizes), then applies validateCreateRequest. @@ -1550,9 +1583,12 @@ func (h *Handler) invoke(w http.ResponseWriter, r *http.Request, name string) { } // toVersionConfiguration renders a published version's configuration. It starts -// from the live function (for name/ARN/state/environment) then overlays the -// immutable per-version fields snapshotted at publish time, so each version -// reports its own CodeSha256/RevisionId/runtime rather than reusing $LATEST. +// from the live function (for name/ARN) then overlays the immutable per-version +// fields snapshotted at publish time, so each version reports its own +// CodeSha256/RevisionId/runtime/environment rather than reusing $LATEST. A +// published version also keeps its own lifecycle: LastModified is its publish +// time and State/LastUpdateStatus come from the version, never from an +// in-flight update of $LATEST. func toVersionConfiguration(info *sdrv.FunctionInfo, ver *sdrv.FunctionVersion, awsCfg *sdrv.AWSFunctionConfig) functionConfiguration { cfg := toConfiguration(info, awsCfg) cfg.Version = ver.Version @@ -1575,11 +1611,33 @@ func toVersionConfiguration(info *sdrv.FunctionInfo, ver *sdrv.FunctionVersion, // keeps the unqualified ARN. if ver.Version != "" && ver.Version != latestVersion { cfg.FunctionArn = info.ARN + ":" + ver.Version + applyVersionLifecycle(&cfg, ver) } return cfg } +// applyVersionLifecycle replaces the $LATEST lifecycle fields toConfiguration +// copied with a published version's own: its publish time as LastModified and +// the State/StateReason/LastUpdateStatus the backend reports for the version. +// A backend that does not report a version lifecycle falls back to +// Active/Successful, which is what an immutable published version settles to. +func applyVersionLifecycle(cfg *functionConfiguration, ver *sdrv.FunctionVersion) { + if ver.CreatedAt != "" { + cfg.LastModified = ver.CreatedAt + } + + cfg.State = ver.State + cfg.StateReason = ver.StateReason + cfg.StateReasonCode = ver.StateReasonCode + cfg.LastUpdateStatus = ver.LastUpdateStatus + + if cfg.State == "" { + cfg.State = stateActive + cfg.LastUpdateStatus = lastUpdateStatusSuccessful + } +} + func toConfiguration(info *sdrv.FunctionInfo, awsCfg *sdrv.AWSFunctionConfig) functionConfiguration { version := info.Version if version == "" { diff --git a/server/aws/lambda/iam_actions.go b/server/aws/lambda/iam_actions.go index 544e7841e..9c715a415 100644 --- a/server/aws/lambda/iam_actions.go +++ b/server/aws/lambda/iam_actions.go @@ -146,7 +146,6 @@ func (c *checkSet) requestTags(tags map[string]string) { c.setList(condTagKeys, keys) } -// resourceTags sets aws:ResourceTag/* from the tags of the function name. // codeSigningConfigTags sets the aws:ResourceTag/ condition keys from a code // signing config's tags. func (c *checkSet) codeSigningConfigTags(arn string) { @@ -165,6 +164,7 @@ func (c *checkSet) codeSigningConfigTags(arn string) { } } +// resourceTags sets aws:ResourceTag/* from the tags of the function name. func (c *checkSet) resourceTags(name string) { tagger, ok := c.h.fn.(functionTagger) if !ok { diff --git a/server/aws/lambda/parity_1518_sdk_test.go b/server/aws/lambda/parity_1518_sdk_test.go index 338ffbc81..eb6a96c6c 100644 --- a/server/aws/lambda/parity_1518_sdk_test.go +++ b/server/aws/lambda/parity_1518_sdk_test.go @@ -16,6 +16,7 @@ import ( lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types" cloudemu "github.com/stackshy/cloudemu/v2" "github.com/stackshy/cloudemu/v2/config" + lambdaprov "github.com/stackshy/cloudemu/v2/providers/aws/lambda" awsserver "github.com/stackshy/cloudemu/v2/server/aws" ) @@ -363,3 +364,271 @@ func TestSDKAsyncSettleWaiters(t *testing.T) { t.Fatalf("FunctionUpdatedV2: %v", err) } } + +// versionSnapshot is the lifecycle a published version reports on the wire. +type versionSnapshot struct { + lastModified, state, stateReasonCode, lastUpdateStatus string +} + +func snapshotOf(cfg *lambdatypes.FunctionConfiguration) versionSnapshot { + return snapshotFields(cfg.LastModified, cfg.State, cfg.StateReasonCode, cfg.LastUpdateStatus) +} + +func snapshotFields( + lastModified *string, state lambdatypes.State, code lambdatypes.StateReasonCode, lus lambdatypes.LastUpdateStatus, +) versionSnapshot { + return versionSnapshot{ + lastModified: aws.ToString(lastModified), state: string(state), + stateReasonCode: string(code), lastUpdateStatus: string(lus), + } +} + +// getVersionSnapshot reads version of name through both GetFunction --qualifier +// and ListVersionsByFunction, failing when the two disagree. +func getVersionSnapshot(t *testing.T, c *awslambda.Client, name, version string) versionSnapshot { + t.Helper() + + ctx := context.Background() + + got, err := c.GetFunction(ctx, &awslambda.GetFunctionInput{FunctionName: aws.String(name), Qualifier: aws.String(version)}) + if err != nil { + t.Fatalf("GetFunction --qualifier %s: %v", version, err) + } + + fromGet := snapshotOf(got.Configuration) + + vers, err := c.ListVersionsByFunction(ctx, &awslambda.ListVersionsByFunctionInput{FunctionName: aws.String(name)}) + if err != nil { + t.Fatal(err) + } + + for i := range vers.Versions { + if aws.ToString(vers.Versions[i].Version) == version { + if fromList := snapshotOf(&vers.Versions[i]); fromList != fromGet { + t.Fatalf("version %s: ListVersionsByFunction %+v != GetFunction %+v", version, fromList, fromGet) + } + + return fromGet + } + } + + t.Fatalf("version %s missing from ListVersionsByFunction", version) + + return versionSnapshot{} +} + +// TestSDKPublishedVersionKeepsItsOwnLifecycle: a published version is +// immutable, so GetFunction --qualifier 1 and ListVersionsByFunction report the +// version's own LastModified (its publish time) and Active/Successful however +// $LATEST changes afterwards, in default mode and under AsyncSettle. +func TestSDKPublishedVersionKeepsItsOwnLifecycle(t *testing.T) { + for _, tc := range []struct { + name string + async bool + }{{"default", false}, {"async-settle", true}} { + t.Run(tc.name, func(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + + opts := []config.Option{config.WithClock(fc)} + if tc.async { + opts = append(opts, config.WithAsyncSettle()) + } + + c := newParityLambdaClient(t, opts...) + ctx := context.Background() + + createParityFunction(t, c, "frozen", []byte("v1-code")) + fc.Advance(5 * time.Second) + + pub, err := c.PublishVersion(ctx, &awslambda.PublishVersionInput{FunctionName: aws.String("frozen")}) + if err != nil { + t.Fatal(err) + } + + // The version's LastModified is its publish time, not $LATEST's. + want := versionSnapshot{ + lastModified: fc.Now().UTC().Format(time.RFC3339), state: string(lambdatypes.StateActive), + lastUpdateStatus: string(lambdatypes.LastUpdateStatusSuccessful), + } + if got := snapshotFields(pub.LastModified, pub.State, pub.StateReasonCode, pub.LastUpdateStatus); got != want { + t.Fatalf("PublishVersion = %+v, want %+v", got, want) + } + + if got := getVersionSnapshot(t, c, "frozen", "1"); got != want { + t.Fatalf("version 1 before $LATEST update = %+v, want %+v", got, want) + } + + fc.Advance(time.Minute) + + upd, err := c.UpdateFunctionCode(ctx, &awslambda.UpdateFunctionCodeInput{ + FunctionName: aws.String("frozen"), ZipFile: []byte("v2-code"), + }) + if err != nil { + t.Fatal(err) + } + + if aws.ToString(upd.LastModified) == want.lastModified { + t.Fatalf("$LATEST LastModified did not move (%s); the test needs it to", want.lastModified) + } + + if tc.async && upd.LastUpdateStatus != lambdatypes.LastUpdateStatusInProgress { + t.Fatalf("$LATEST LastUpdateStatus = %q, want InProgress", upd.LastUpdateStatus) + } + + if got := getVersionSnapshot(t, c, "frozen", "1"); got != want { + t.Errorf("version 1 after $LATEST update = %+v, want unchanged %+v", got, want) + } + + // $LATEST itself still reports the live update. + if got := getVersionSnapshot(t, c, "frozen", "$LATEST"); got != snapshotFields(upd.LastModified, upd.State, upd.StateReasonCode, upd.LastUpdateStatus) { + t.Errorf("$LATEST = %+v, want %+v", got, snapshotFields(upd.LastModified, upd.State, upd.StateReasonCode, upd.LastUpdateStatus)) + } + }) + } +} + +// TestSDKPublishOnCreateVersionIsPendingUntilCreated: the version +// CreateFunction(Publish=true) cuts is Pending until creation completes, then +// Active/Successful, and a later $LATEST update leaves it untouched. +func TestSDKPublishOnCreateVersionIsPendingUntilCreated(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + c := newParityLambdaClient(t, config.WithClock(fc), config.WithAsyncSettle()) + ctx := context.Background() + + created, err := c.CreateFunction(ctx, &awslambda.CreateFunctionInput{ + FunctionName: aws.String("pubcreate"), + Runtime: lambdatypes.RuntimePython312, + Role: aws.String("arn:aws:iam::123456789012:role/test"), + Handler: aws.String("main.handler"), + Code: &lambdatypes.FunctionCode{ZipFile: []byte("zip")}, + Publish: true, + }) + if err != nil { + t.Fatal(err) + } + + pending := versionSnapshot{ + lastModified: aws.ToString(created.LastModified), state: string(lambdatypes.StatePending), + stateReasonCode: string(lambdatypes.StateReasonCodeCreating), + } + if aws.ToString(created.Version) != "1" || snapshotFields(created.LastModified, created.State, created.StateReasonCode, created.LastUpdateStatus) != pending { + t.Fatalf("CreateFunction(Publish) = %s %+v, want 1 %+v", aws.ToString(created.Version), snapshotFields(created.LastModified, created.State, created.StateReasonCode, created.LastUpdateStatus), pending) + } + + if got := getVersionSnapshot(t, c, "pubcreate", "1"); got != pending { + t.Fatalf("version 1 while creating = %+v, want %+v", got, pending) + } + + fc.Advance(2 * time.Second) + + active := versionSnapshot{ + lastModified: pending.lastModified, state: string(lambdatypes.StateActive), + lastUpdateStatus: string(lambdatypes.LastUpdateStatusSuccessful), + } + if got := getVersionSnapshot(t, c, "pubcreate", "1"); got != active { + t.Fatalf("version 1 once created = %+v, want %+v", got, active) + } + + fc.Advance(time.Minute) + + if _, err := c.UpdateFunctionConfiguration(ctx, &awslambda.UpdateFunctionConfigurationInput{ + FunctionName: aws.String("pubcreate"), Timeout: aws.Int32(9), + }); err != nil { + t.Fatal(err) + } + + if got := getVersionSnapshot(t, c, "pubcreate", "1"); got != active { + t.Errorf("version 1 after $LATEST update = %+v, want unchanged %+v", got, active) + } +} + +// cscDeletedBeforeAttach is a Lambda backend whose code signing config is +// deleted after CreateFunction's existence check but before the attach, the +// race the create path must not leave a half-created function behind for. +type cscDeletedBeforeAttach struct { + *lambdaprov.Mock +} + +func (b cscDeletedBeforeAttach) PutFunctionCodeSigningConfig(ctx context.Context, name, arn string) error { + if err := b.DeleteCodeSigningConfig(ctx, arn); err != nil { + return err + } + + return b.Mock.PutFunctionCodeSigningConfig(ctx, name, arn) +} + +// TestSDKCreateFunctionRollsBackWhenCodeSigningAttachFails: when the code +// signing config disappears between CreateFunction's check and the attach, the +// request fails with CodeSigningConfigNotFoundException and no function is left +// behind, so a retry is not a ResourceConflictException. +func TestSDKCreateFunctionRollsBackWhenCodeSigningAttachFails(t *testing.T) { + cloud := cloudemu.NewAWS() + ts := httptest.NewServer(awsserver.New(awsserver.Drivers{Lambda: cscDeletedBeforeAttach{cloud.Lambda}})) + t.Cleanup(ts.Close) + + cfg, err := awsconfig.LoadDefaultConfig(context.Background(), + awsconfig.WithRegion("us-east-1"), + awsconfig.WithCredentialsProvider(credentials.NewStaticCredentialsProvider("test", "test", "")), + ) + if err != nil { + t.Fatal(err) + } + + c := awslambda.NewFromConfig(cfg, func(o *awslambda.Options) { o.BaseEndpoint = aws.String(ts.URL) }) + ctx := context.Background() + + csc, err := c.CreateCodeSigningConfig(ctx, &awslambda.CreateCodeSigningConfigInput{ + AllowedPublishers: &lambdatypes.AllowedPublishers{ + SigningProfileVersionArns: []string{"arn:aws:signer:us-east-1:123456789012:/signing-profiles/p/abc"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + in := &awslambda.CreateFunctionInput{ + FunctionName: aws.String("signed"), + Runtime: lambdatypes.RuntimePython312, + Role: aws.String("arn:aws:iam::123456789012:role/test"), + Handler: aws.String("main.handler"), + Code: &lambdatypes.FunctionCode{ZipFile: []byte("zip")}, + CodeSigningConfigArn: csc.CodeSigningConfig.CodeSigningConfigArn, + } + + _, err = c.CreateFunction(ctx, in) + + var notFound *lambdatypes.CodeSigningConfigNotFoundException + if !errors.As(err, ¬Found) { + t.Fatalf("create err = %v, want CodeSigningConfigNotFoundException", err) + } + + _, err = c.GetFunction(ctx, &awslambda.GetFunctionInput{FunctionName: aws.String("signed")}) + + var rnf *lambdatypes.ResourceNotFoundException + if !errors.As(err, &rnf) { + t.Fatalf("GetFunction after failed create err = %v, want ResourceNotFoundException (rolled back)", err) + } + + in.CodeSigningConfigArn = nil + if _, err := c.CreateFunction(ctx, in); err != nil { + t.Fatalf("retry create err = %v, want success", err) + } + + // The standalone PutFunctionCodeSigningConfig hits the same race and reports + // the config's error, not the function's ResourceNotFoundException. + csc2, err := c.CreateCodeSigningConfig(ctx, &awslambda.CreateCodeSigningConfigInput{ + AllowedPublishers: &lambdatypes.AllowedPublishers{ + SigningProfileVersionArns: []string{"arn:aws:signer:us-east-1:123456789012:/signing-profiles/p/abc"}, + }, + }) + if err != nil { + t.Fatal(err) + } + + _, err = c.PutFunctionCodeSigningConfig(ctx, &awslambda.PutFunctionCodeSigningConfigInput{ + FunctionName: aws.String("signed"), CodeSigningConfigArn: csc2.CodeSigningConfig.CodeSigningConfigArn, + }) + if !errors.As(err, ¬Found) { + t.Fatalf("put err = %v, want CodeSigningConfigNotFoundException", err) + } +} diff --git a/services/serverless/driver/driver.go b/services/serverless/driver/driver.go index 9d6f09e9d..f957ebb73 100644 --- a/services/serverless/driver/driver.go +++ b/services/serverless/driver/driver.go @@ -26,6 +26,16 @@ type FunctionVersion struct { CodeSize int64 // Environment is the environment-variable set snapshotted at publish time. Environment map[string]string + // State, StateReason, StateReasonCode and LastUpdateStatus are a published + // version's own lifecycle. A published version is immutable, so it does not + // follow later $LATEST updates: it is Active/Successful, except the version + // CreateFunction(Publish=true) cuts, which is Pending with the function until + // creation completes. Empty for $LATEST (read FunctionInfo) and for a + // backend that does not model the lifecycle. + State string + StateReason string + StateReasonCode string + LastUpdateStatus string } // PermissionStatement is one statement of a function's resource-based policy, From dca3dcf17543f3646ac182425c91724d79ca9322 Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Sat, 24 Oct 2026 15:09:50 +0530 Subject: [PATCH 3/7] refactor(aws-lambda): keep handler dispatch under the complexity limit Split the code signing and account settings branches out of ServeHTTP, Matches and classify into small helpers, group the two concurrency routes, and move CreateFunction's validation/config build and Invoke's DryRun and synchronous response writing into their own functions. Name the repeated response field strings as constants. No behaviour change. --- server/aws/lambda/classify.go | 30 ++++- server/aws/lambda/code_signing.go | 26 +++- server/aws/lambda/handler.go | 211 +++++++++++++++++++----------- 3 files changed, 181 insertions(+), 86 deletions(-) diff --git a/server/aws/lambda/classify.go b/server/aws/lambda/classify.go index 1e7a590c8..f780d790a 100644 --- a/server/aws/lambda/classify.go +++ b/server/aws/lambda/classify.go @@ -198,13 +198,10 @@ func classify(r *http.Request) (opID, opArgs) { return classifyEventInvokeConfig(r, trimRoute(path, eventInvokeConfigPrefix)) case isProvisionedConcurrencyPath(path): return classifyProvisionedConcurrency(r) - case isCodeSigningPath(path): - return classifyCodeSigning(r) - case strings.HasPrefix(path, codeSigningConfigsPrefix): - return classifyCodeSigningConfigs(r, trimRoute(path, codeSigningConfigsPrefix)) - case path == accountSettingsPath: - return methodOp(r.Method, map[string]opID{http.MethodGet: opGetAccountSettings}), - opArgs{route: routeAccountSettings} + } + + if op, a, ok := classifyCodeSigningOrAccount(r, path); ok { + return op, a } if name, ok := concurrencyFunctionName(path); ok { @@ -217,6 +214,25 @@ func classify(r *http.Request) (opID, opArgs) { return classifyFunctions(r) } +// classifyCodeSigningOrAccount classifies the code signing config API, a +// function's code-signing-config sub-resource and the account settings API. +// ok is false for any other path. +func classifyCodeSigningOrAccount(r *http.Request, path string) (op opID, a opArgs, ok bool) { + switch { + case isCodeSigningPath(path): + op, a = classifyCodeSigning(r) + case strings.HasPrefix(path, codeSigningConfigsPrefix): + op, a = classifyCodeSigningConfigs(r, trimRoute(path, codeSigningConfigsPrefix)) + case path == accountSettingsPath: + op = methodOp(r.Method, map[string]opID{http.MethodGet: opGetAccountSettings}) + a = opArgs{route: routeAccountSettings} + default: + return opUnknown, opArgs{}, false + } + + return op, a, true +} + // trimRoute strips a route prefix and the slash after it. func trimRoute(path, prefix string) string { return strings.TrimPrefix(strings.TrimPrefix(path, prefix), "/") diff --git a/server/aws/lambda/code_signing.go b/server/aws/lambda/code_signing.go index b88a70a04..452eceeea 100644 --- a/server/aws/lambda/code_signing.go +++ b/server/aws/lambda/code_signing.go @@ -22,6 +22,20 @@ const ( // CreateFunction return for an unknown code signing config ARN (HTTP 404). const codeSigningConfigNotFound = "CodeSigningConfigNotFoundException" +// Response body field names shared by more than one Lambda operation. +const ( + // bodyTags is the tag map of a ListTags response. + bodyTags = "Tags" + // bodyCodeSigningConfig wraps a code signing config in a response. + bodyCodeSigningConfig = "CodeSigningConfig" + // bodyCodeSigningConfigArn is the config ARN of a function's code signing + // config response. + bodyCodeSigningConfigArn = "CodeSigningConfigArn" + // bodyFunctionName is the function name of a function's code signing + // config response. + bodyFunctionName = "FunctionName" +) + // codeSigningManager is the AWS-only code signing surface (code signing // configs and their association with functions). It has no Azure Functions or // GCP Cloud Functions equivalent, so the handler type-asserts for it rather than @@ -139,7 +153,7 @@ func (h *Handler) serveFunctionCodeSigningConfig(w http.ResponseWriter, r *http. arn = got } - writeJSON(w, http.StatusOK, map[string]any{"FunctionName": name, "CodeSigningConfigArn": arn}) + writeJSON(w, http.StatusOK, map[string]any{bodyFunctionName: name, bodyCodeSigningConfigArn: arn}) case opPutFunctionCodeSigningConfig: h.putFunctionCodeSigningConfig(w, r, csm, ok, name) case opDeleteFunctionCodeSigningConfig: @@ -189,7 +203,7 @@ func (h *Handler) putFunctionCodeSigningConfig( } writeJSON(w, http.StatusOK, map[string]string{ - "FunctionName": name, "CodeSigningConfigArn": req.CodeSigningConfigArn, + bodyFunctionName: name, bodyCodeSigningConfigArn: req.CodeSigningConfigArn, }) } @@ -240,7 +254,7 @@ func (h *Handler) serveCodeSigningConfigs(w http.ResponseWriter, r *http.Request return } - writeJSON(w, http.StatusOK, map[string]any{"CodeSigningConfig": toCodeSigningConfigEnvelope(c)}) + writeJSON(w, http.StatusOK, map[string]any{bodyCodeSigningConfig: toCodeSigningConfigEnvelope(c)}) case opUpdateCodeSigningConfig: h.updateCodeSigningConfig(w, r, csm, a.item) case opDeleteCodeSigningConfig: @@ -325,7 +339,7 @@ func (*Handler) createCodeSigningConfig(w http.ResponseWriter, r *http.Request, return } - writeJSON(w, http.StatusCreated, map[string]any{"CodeSigningConfig": toCodeSigningConfigEnvelope(c)}) + writeJSON(w, http.StatusCreated, map[string]any{bodyCodeSigningConfig: toCodeSigningConfigEnvelope(c)}) } // updateCodeSigningConfig serves UpdateCodeSigningConfig. @@ -354,7 +368,7 @@ func (*Handler) updateCodeSigningConfig(w http.ResponseWriter, r *http.Request, return } - writeJSON(w, http.StatusOK, map[string]any{"CodeSigningConfig": toCodeSigningConfigEnvelope(c)}) + writeJSON(w, http.StatusOK, map[string]any{bodyCodeSigningConfig: toCodeSigningConfigEnvelope(c)}) } // codeSigningConfigARNMarker identifies a code signing config ARN @@ -418,7 +432,7 @@ func (h *Handler) serveCodeSigningConfigTags(w http.ResponseWriter, r *http.Requ tags = map[string]string{} } - writeJSON(w, http.StatusOK, map[string]any{"Tags": tags}) + writeJSON(w, http.StatusOK, map[string]any{bodyTags: tags}) default: writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") } diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index a74b94ea1..5d293aa31 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -293,9 +293,16 @@ func (*Handler) Matches(r *http.Request) bool { strings.HasPrefix(r.URL.Path, layersPrefix) || strings.HasPrefix(r.URL.Path, functionURLPrefix) || strings.HasPrefix(r.URL.Path, eventInvokeConfigPrefix) || - strings.HasPrefix(r.URL.Path, codeSigningConfigsPrefix) || - r.URL.Path == accountSettingsPath || - isCodeSigningPath(r.URL.Path) + isCodeSigningOrAccountPath(r.URL.Path) +} + +// isCodeSigningOrAccountPath reports whether path is under the code signing +// config API, a function's code-signing-config sub-resource, or the account +// settings API. +func isCodeSigningOrAccountPath(path string) bool { + return strings.HasPrefix(path, codeSigningConfigsPrefix) || + path == accountSettingsPath || + isCodeSigningPath(path) } // ServeHTTP runs the operation classify picks for the request. @@ -310,17 +317,7 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { case routeFunctionURLInvoke: h.serveFunctionURLInvoke(w, r) case routeTags: - if foreignRef(&a, h.accountID, h.region) { - writeForeignRef(w, a.item) - return - } - - if isCodeSigningConfigARN(a.item) { - h.serveCodeSigningConfigTags(w, r, op, a.item) - return - } - - h.serveTags(w, r, op, a.name) + h.serveTagsRoute(w, r, op, &a) case routeEventSourceMappings: h.serveEventSourceMappings(w, r, op, a.item) case routeLayers: @@ -329,21 +326,60 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { h.serveFunctionURL(w, r, op, &a) case routeEventInvokeConfig: h.serveEventInvokeConfig(w, r, op, &a) - case routeProvisionedConcurrency: - h.serveProvisionedConcurrency(w, r, op, &a) - case routeCodeSigning: - h.serveFunctionCodeSigningConfig(w, r, op, a.name) - case routeCodeSigningConfigs: - h.serveCodeSigningConfigs(w, r, op, &a) - case routeAccountSettings: - h.serveAccountSettings(w, r) - case routeConcurrency: - h.serveConcurrency(w, r, op, a.name) + case routeProvisionedConcurrency, routeConcurrency: + h.serveConcurrencyRoute(w, r, op, &a) + case routeCodeSigning, routeCodeSigningConfigs, routeAccountSettings: + h.serveCodeSigningOrAccount(w, r, op, &a) case routeFunctions: h.serveFunctions(w, r, op, &a) } } +// serveTagsRoute serves /2017-03-31/tags/{arn}: a function's tags, or a code +// signing config's when the ARN names one. A cross-account or cross-region ARN +// is rejected first. +func (h *Handler) serveTagsRoute(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { + if foreignRef(a, h.accountID, h.region) { + writeForeignRef(w, a.item) + return + } + + if isCodeSigningConfigARN(a.item) { + h.serveCodeSigningConfigTags(w, r, op, a.item) + return + } + + h.serveTags(w, r, op, a.name) +} + +// serveCodeSigningOrAccount serves the code signing config API +// (routeCodeSigningConfigs), a function's code-signing-config sub-resource +// (routeCodeSigning) and the account settings API (routeAccountSettings). +func (h *Handler) serveCodeSigningOrAccount(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { + if a.route == routeAccountSettings { + h.serveAccountSettings(w, r) + return + } + + if a.route == routeCodeSigning { + h.serveFunctionCodeSigningConfig(w, r, op, a.name) + return + } + + h.serveCodeSigningConfigs(w, r, op, a) +} + +// serveConcurrencyRoute serves a function's provisioned concurrency +// (routeProvisionedConcurrency) or reserved concurrency (routeConcurrency). +func (h *Handler) serveConcurrencyRoute(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { + if a.route == routeProvisionedConcurrency { + h.serveProvisionedConcurrency(w, r, op, a) + return + } + + h.serveConcurrency(w, r, op, a.name) +} + // serveFunctions serves the /2015-03-31/functions tree. func (h *Handler) serveFunctions(w http.ResponseWriter, r *http.Request, op opID, a *opArgs) { if len(a.parts) == 0 { @@ -439,7 +475,7 @@ func (h *Handler) serveTags(w http.ResponseWriter, r *http.Request, op opID, nam return } - writeJSON(w, http.StatusOK, map[string]any{"Tags": tags}) + writeJSON(w, http.StatusOK, map[string]any{bodyTags: tags}) default: writeError(w, http.StatusMethodNotAllowed, "InvalidRequestException", "method not allowed") } @@ -1026,24 +1062,70 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { return } - if !checkModelConstraints(w, req.Role, req.Environment) { + if !h.validateCreate(w, r, &req) { return } - if err := h.checkCreateRequest(&req); err != nil { + cfg, err := h.createFunctionConfig(r.Context(), &req) + if err != nil { writeErr(w, err) return } - if err := h.validateLayers(r.Context(), req.Layers); err != nil { + info, err := h.fn.CreateFunction(r.Context(), cfg) + if err != nil { writeErr(w, err) return } - if !h.checkCodeSigningConfigExists(w, r, req.CodeSigningConfigArn) { + awsCfg := h.applyAWSConfig(r.Context(), req.FunctionName, h.createAWSConfig(&req), true) + + if !h.attachCreateCodeSigningConfig(w, r, req.FunctionName, req.CodeSigningConfigArn) { return } + // Publish=true cuts version 1 from the just-created function and returns that + // published version's configuration (Version "1", :1-qualified ARN), matching + // AWS and Terraform's aws_lambda_function{publish=true}. + if req.Publish { + cfg, perr := h.publishConfiguration(r.Context(), req.FunctionName, req.Description, info, awsCfg) + if perr != nil { + writeErr(w, perr) + return + } + + writeJSON(w, http.StatusCreated, cfg) + + return + } + + writeJSON(w, http.StatusCreated, toConfiguration(info, awsCfg)) +} + +// validateCreate runs the CreateFunction checks that precede any state change: +// model constraints, the request itself, its layers and its code signing +// config. It writes the error and returns false on the first failure. +func (h *Handler) validateCreate(w http.ResponseWriter, r *http.Request, req *createFunctionRequest) bool { + if !checkModelConstraints(w, req.Role, req.Environment) { + return false + } + + if err := h.checkCreateRequest(req); err != nil { + writeErr(w, err) + return false + } + + if err := h.validateLayers(r.Context(), req.Layers); err != nil { + writeErr(w, err) + return false + } + + return h.checkCodeSigningConfigExists(w, r, req.CodeSigningConfigArn) +} + +// createFunctionConfig builds the portable FunctionConfig a CreateFunction +// request creates, with its code resolved and its layers overlaid. +func (h *Handler) createFunctionConfig(ctx context.Context, req *createFunctionRequest) (sdrv.FunctionConfig, error) { cfg := sdrv.FunctionConfig{ Name: req.FunctionName, Runtime: req.Runtime, @@ -1058,48 +1140,19 @@ func (h *Handler) create(w http.ResponseWriter, r *http.Request) { cfg.Environment = req.Environment.Variables } - code, err := h.resolveCode(r.Context(), req.Code) + code, err := h.resolveCode(ctx, req.Code) if err != nil { - writeErr(w, err) - return + return sdrv.FunctionConfig{}, err } code, err = h.overlayLayers(code, req.Layers) if err != nil { - writeErr(w, err) - return + return sdrv.FunctionConfig{}, err } cfg.Code = code - info, err := h.fn.CreateFunction(r.Context(), cfg) - if err != nil { - writeErr(w, err) - return - } - - awsCfg := h.applyAWSConfig(r.Context(), req.FunctionName, h.createAWSConfig(&req), true) - - if !h.attachCreateCodeSigningConfig(w, r, req.FunctionName, req.CodeSigningConfigArn) { - return - } - - // Publish=true cuts version 1 from the just-created function and returns that - // published version's configuration (Version "1", :1-qualified ARN), matching - // AWS and Terraform's aws_lambda_function{publish=true}. - if req.Publish { - cfg, perr := h.publishConfiguration(r.Context(), req.FunctionName, req.Description, info, awsCfg) - if perr != nil { - writeErr(w, perr) - return - } - - writeJSON(w, http.StatusCreated, cfg) - - return - } - - writeJSON(w, http.StatusCreated, toConfiguration(info, awsCfg)) + return cfg, nil } // attachCreateCodeSigningConfig attaches the CodeSigningConfigArn of a @@ -1507,17 +1560,7 @@ func (h *Handler) invoke(w http.ResponseWriter, r *http.Request, name string) { // alias/version fails with ResourceNotFoundException rather than a spurious // 204. if invokeType == invocationTypeDryRun { - if _, err = h.fn.Invoke(r.Context(), sdrv.InvokeInput{ - FunctionName: functionName, - InvokeType: invocationTypeDryRun, - Qualifier: qualifier, - }); err != nil { - writeErr(w, err) - return - } - - w.WriteHeader(http.StatusNoContent) - + h.invokeDryRun(w, r, functionName, qualifier) return } @@ -1539,6 +1582,28 @@ func (h *Handler) invoke(w http.ResponseWriter, r *http.Request, name string) { return } + writeInvokeResult(w, r, out) +} + +// invokeDryRun serves a DryRun invocation: it checks that the function and +// qualifier exist without running anything and answers 204 No Content. +func (h *Handler) invokeDryRun(w http.ResponseWriter, r *http.Request, functionName, qualifier string) { + if _, err := h.fn.Invoke(r.Context(), sdrv.InvokeInput{ + FunctionName: functionName, + InvokeType: invocationTypeDryRun, + Qualifier: qualifier, + }); err != nil { + writeErr(w, err) + return + } + + w.WriteHeader(http.StatusNoContent) +} + +// writeInvokeResult writes the response of a synchronous (RequestResponse) +// invocation: the executed version and log tail headers, then the payload, or +// the handler error with X-Amz-Function-Error. +func writeInvokeResult(w http.ResponseWriter, r *http.Request, out *sdrv.InvokeOutput) { w.Header().Set("Content-Type", contentTypeJSON) // A synchronous (RequestResponse) invocation always reports the version that From 2dc7124c55c44d9b061e32aad5c54ae637b981e3 Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Thu, 22 Oct 2026 16:30:07 +0530 Subject: [PATCH 4/7] fix(aws-lambda): match real not-found behaviour for DeleteAlias, UpdateAlias and DeleteFunction --qualifier Real Lambda, as recorded by AWS-validated snapshot tests: - DeleteAlias of an alias the function does not have returns 204 (no ResourceNotFoundException, which is not in the DeleteAlias error list). - UpdateAlias of a missing alias is ResourceNotFoundException with the message "Alias not found: :". - DeleteFunction with a numbered Qualifier the function does not have is an idempotent 204; a missing function is still "Function not found: ". A missing function stays ResourceNotFoundException for all three. A non-numeric unknown DeleteFunction qualifier keeps its current error, as its real behaviour is unverified. Part of #1665 --- providers/aws/lambda/alias_notfound_test.go | 64 ++++++++++++++++++++ providers/aws/lambda/aliases.go | 10 ++- providers/aws/lambda/lambda_test.go | 6 +- providers/aws/lambda/versions.go | 18 ++++-- server/aws/lambda/alias_notfound_sdk_test.go | 50 +++++++++++++++ server/aws/lambda/qualified_name_sdk_test.go | 34 ++++++++--- 6 files changed, 167 insertions(+), 15 deletions(-) create mode 100644 providers/aws/lambda/alias_notfound_test.go create mode 100644 server/aws/lambda/alias_notfound_sdk_test.go diff --git a/providers/aws/lambda/alias_notfound_test.go b/providers/aws/lambda/alias_notfound_test.go new file mode 100644 index 000000000..1db567594 --- /dev/null +++ b/providers/aws/lambda/alias_notfound_test.go @@ -0,0 +1,64 @@ +package lambda + +import ( + "context" + "testing" + + cerrors "github.com/stackshy/cloudemu/v2/errors" + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// TestAliasAndVersionNotFoundParity: UpdateAlias on a missing alias uses real +// Lambda's "Alias not found: :" text; DeleteAlias of a +// missing alias and DeleteFunction of a missing numbered version succeed +// without changing anything, while a missing function is still NotFound. +func TestAliasAndVersionNotFoundParity(t *testing.T) { + ctx := context.Background() + m := newTestMock() + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + if _, err := m.PublishVersion(ctx, "my-func", ""); err != nil { + t.Fatal(err) + } + + if _, err := m.CreateAlias(ctx, driver.AliasConfig{FunctionName: "my-func", Name: "live", FunctionVersion: "1"}); err != nil { + t.Fatal(err) + } + + _, err := m.UpdateAlias(ctx, driver.AliasConfig{FunctionName: "my-func", Name: "nope", FunctionVersion: "1"}) + if !cerrors.IsNotFound(err) { + t.Fatalf("UpdateAlias(missing) err = %v, want NotFound", err) + } + + if got, want := cerrors.Message(err), + "Alias not found: arn:aws:lambda:us-east-1:123456789012:function:my-func:nope"; got != want { + t.Errorf("UpdateAlias message = %q, want %q", got, want) + } + + if err := m.DeleteAlias(ctx, "my-func", "nope"); err != nil { + t.Errorf("DeleteAlias(missing alias) err = %v, want nil", err) + } + + if _, err := m.GetAlias(ctx, "my-func", "live"); err != nil { + t.Errorf("DeleteAlias(missing alias) touched another alias: %v", err) + } + + if err := m.DeleteAlias(ctx, "ghost", "live"); !cerrors.IsNotFound(err) { + t.Errorf("DeleteAlias(missing function) err = %v, want NotFound", err) + } + + if err := m.DeleteVersion(ctx, "my-func", "99"); err != nil { + t.Errorf("DeleteVersion(missing version) err = %v, want nil", err) + } + + if err := m.DeleteVersion(ctx, "ghost", "1"); !cerrors.IsNotFound(err) { + t.Errorf("DeleteVersion(missing function) err = %v, want NotFound", err) + } + + if err := m.DeleteVersion(ctx, "my-func", "not-an-alias"); !cerrors.IsNotFound(err) { + t.Errorf("DeleteVersion(unknown non-numeric qualifier) err = %v, want NotFound (unchanged)", err) + } +} diff --git a/providers/aws/lambda/aliases.go b/providers/aws/lambda/aliases.go index 50458f14e..f4b0dc940 100644 --- a/providers/aws/lambda/aliases.go +++ b/providers/aws/lambda/aliases.go @@ -67,7 +67,8 @@ func (m *Mock) UpdateAlias(ctx context.Context, cfg driver.AliasConfig) (*driver ad, ok := fd.aliases.Get(cfg.Name) if !ok { - return nil, cerrors.Newf(cerrors.NotFound, "alias %s not found", cfg.Name) + // Real Lambda's UpdateAlias text names the alias-qualified function ARN. + return nil, cerrors.Newf(cerrors.NotFound, "Alias not found: %s:%s", fd.info.ARN, cfg.Name) } // ad is a shared pointer held in the store; guard the read-validate-mutate of @@ -121,6 +122,11 @@ func (m *Mock) UpdateAlias(ctx context.Context, cfg driver.AliasConfig) (*driver // provisioned concurrency config and event invoke config. Real Lambda keeps // these as sub-resources of the alias ARN, so a later alias with the same name // starts with none of them instead of inheriting the old grants. +// +// Deleting an alias the function does not have succeeds without changing +// anything, as real Lambda does (HTTP 204; ResourceNotFoundException is not in +// the DeleteAlias error list). A missing function is still +// ResourceNotFoundException. func (m *Mock) DeleteAlias(ctx context.Context, functionName, aliasName string) error { m.mu.Lock() defer m.mu.Unlock() @@ -131,7 +137,7 @@ func (m *Mock) DeleteAlias(ctx context.Context, functionName, aliasName string) } if !fd.aliases.Has(aliasName) { - return cerrors.Newf(cerrors.NotFound, "alias %s not found", aliasName) + return nil } fd.aliases.Delete(aliasName) diff --git a/providers/aws/lambda/lambda_test.go b/providers/aws/lambda/lambda_test.go index 2c61a56e8..7a4ded3ec 100644 --- a/providers/aws/lambda/lambda_test.go +++ b/providers/aws/lambda/lambda_test.go @@ -459,9 +459,11 @@ func TestDeleteAlias(t *testing.T) { assertError(t, err, true) }) - t.Run("alias not found", func(t *testing.T) { + // Real Lambda answers DeleteAlias for an alias the function does not have + // with 204 and no error. + t.Run("alias not found is a no-op", func(t *testing.T) { err := m.DeleteAlias(ctx, "my-func", "nope") - assertError(t, err, true) + requireNoError(t, err) }) t.Run("function not found", func(t *testing.T) { diff --git a/providers/aws/lambda/versions.go b/providers/aws/lambda/versions.go index 5eee0ad5d..1afb31295 100644 --- a/providers/aws/lambda/versions.go +++ b/providers/aws/lambda/versions.go @@ -212,10 +212,12 @@ func (m *Mock) GetVersionAWSConfig(ctx context.Context, name, version string) (d // can be removed, deleting $LATEST is rejected, and a version an alias still // references cannot be removed until the alias is deleted. It backs the AWS // server handler's version-scoped DeleteFunction (?Qualifier= or a -// "name:qualifier" FunctionName); an unqualified DeleteFunction still removes the -// whole function (all versions and aliases) via DeleteFunction. It has no Azure -// Functions / GCP Cloud Functions equivalent, so it is an AWS-only capability -// asserted by the handler rather than part of the portable Serverless driver. +// "name:qualifier" FunctionName). Deleting a numbered version that does not +// exist is a no-op success; a missing function is ResourceNotFoundException. An +// unqualified DeleteFunction still removes the whole function (all versions and +// aliases) via DeleteFunction. It has no Azure Functions / GCP Cloud Functions +// equivalent, so it is an AWS-only capability asserted by the handler rather +// than part of the portable Serverless driver. func (m *Mock) DeleteVersion(ctx context.Context, name, qualifier string) error { m.mu.Lock() defer m.mu.Unlock() @@ -239,6 +241,14 @@ func (m *Mock) DeleteVersion(ctx context.Context, name, qualifier string) error } if !m.versionExists(&fd, qualifier) { + // Real Lambda treats deleting a numbered version the function does not + // have as a no-op success: version-scoped DeleteFunction is idempotent + // while the function exists. Any other unknown qualifier keeps the + // emulator's not-found error (real behavior for it is unverified). + if isVersionQualifier(qualifier) { + return nil + } + return cerrors.Newf(cerrors.NotFound, "function version %s not found", qualifier) } diff --git a/server/aws/lambda/alias_notfound_sdk_test.go b/server/aws/lambda/alias_notfound_sdk_test.go new file mode 100644 index 000000000..da73c3537 --- /dev/null +++ b/server/aws/lambda/alias_notfound_sdk_test.go @@ -0,0 +1,50 @@ +package lambda_test + +import ( + "context" + "errors" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + awslambda "github.com/aws/aws-sdk-go-v2/service/lambda" + lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types" +) + +// TestSDKAliasNotFoundParity: DeleteAlias of an alias the function does not +// have succeeds (HTTP 204), UpdateAlias of one is ResourceNotFoundException +// "Alias not found: :", and DeleteAlias on a missing +// function is still ResourceNotFoundException. +func TestSDKAliasNotFoundParity(t *testing.T) { + c := newParityLambdaClient(t) + ctx := context.Background() + + created := createParityFunction(t, c, "aliasfn", []byte("zip")) + + if _, err := c.DeleteAlias(ctx, &awslambda.DeleteAliasInput{ + FunctionName: aws.String("aliasfn"), Name: aws.String("non-existent"), + }); err != nil { + t.Errorf("DeleteAlias(missing alias) error = %v, want success", err) + } + + _, err := c.UpdateAlias(ctx, &awslambda.UpdateAliasInput{ + FunctionName: aws.String("aliasfn"), Name: aws.String("non-existent"), + }) + + var nf *lambdatypes.ResourceNotFoundException + if !errors.As(err, &nf) { + t.Fatalf("UpdateAlias(missing alias) error = %v, want ResourceNotFoundException", err) + } + + if want := "Alias not found: " + aws.ToString(created.FunctionArn) + ":non-existent"; aws.ToString(nf.Message) != want { + t.Errorf("UpdateAlias message = %q, want %q", aws.ToString(nf.Message), want) + } + + // ResourceNotFoundException is not a modeled DeleteAlias error, so the SDK + // surfaces it as a generic API error; check the code. + _, err = c.DeleteAlias(ctx, &awslambda.DeleteAliasInput{ + FunctionName: aws.String("ghost"), Name: aws.String("live"), + }) + if errorCode(err) != "ResourceNotFoundException" { + t.Errorf("DeleteAlias(missing function) error = %v, want ResourceNotFoundException", err) + } +} diff --git a/server/aws/lambda/qualified_name_sdk_test.go b/server/aws/lambda/qualified_name_sdk_test.go index ca8049846..19cccd3aa 100644 --- a/server/aws/lambda/qualified_name_sdk_test.go +++ b/server/aws/lambda/qualified_name_sdk_test.go @@ -2,6 +2,8 @@ package lambda_test import ( "context" + "errors" + "strings" "testing" "github.com/aws/aws-sdk-go-v2/aws" @@ -312,24 +314,42 @@ func TestSDKDeleteFunctionLatestQualifierErrors(t *testing.T) { } } -// TestSDKDeleteFunctionMissingVersion verifies deleting a non-existent version -// qualifier is a ResourceNotFoundException and deletes nothing. +// TestSDKDeleteFunctionMissingVersion verifies deleting a version the function +// does not have succeeds and deletes nothing: real Lambda's version-scoped +// DeleteFunction is idempotent while the function exists. A missing function is +// still ResourceNotFoundException with the unqualified function ARN. func TestSDKDeleteFunctionMissingVersion(t *testing.T) { client, _ := newSDKClient(t) ctx := context.Background() publishV1(t, client, "foo") - _, err := client.DeleteFunction(ctx, &awslambda.DeleteFunctionInput{ - FunctionName: aws.String("foo:99"), - }) - if errorCode(err) != "ResourceNotFoundException" { - t.Fatalf("DeleteFunction(foo:99) error = %v, want ResourceNotFoundException", err) + for range 2 { + if _, err := client.DeleteFunction(ctx, &awslambda.DeleteFunctionInput{ + FunctionName: aws.String("foo:99"), + }); err != nil { + t.Fatalf("DeleteFunction(foo:99) error = %v, want success", err) + } } if got := listVersionNumbers(t, client, "foo"); !got["$LATEST"] || !got["1"] { t.Errorf("DeleteFunction(foo:99) altered versions = %v, want $LATEST and 1 intact", got) } + + _, err := client.DeleteFunction(ctx, &awslambda.DeleteFunctionInput{ + FunctionName: aws.String("ghost"), Qualifier: aws.String("99"), + }) + + var nf *lambdatypes.ResourceNotFoundException + if !errors.As(err, &nf) { + t.Fatalf("DeleteFunction(ghost, Qualifier=99) error = %v, want ResourceNotFoundException", err) + } + + // The message names the unqualified function ARN, without the qualifier. + if msg := aws.ToString(nf.Message); !strings.HasPrefix(msg, "Function not found: arn:aws:lambda:") || + !strings.HasSuffix(msg, ":function:ghost") { + t.Errorf("message = %q, want Function not found: ", msg) + } } // TestSDKDeleteFunctionVersionWithAliasErrors verifies a version an alias still From d7ae18011789d3e76eeb74a01e77a5b4415e65ac Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Thu, 22 Oct 2026 16:30:16 +0530 Subject: [PATCH 5/7] fix(aws-lambda): keep serving the previous revision while an update is InProgress Under --async-settle an UpdateFunctionCode/UpdateFunctionConfiguration opens an InProgress window. Real Lambda documents that "invocations continue to use the function's previous code and configuration until the update completes", but the emulator ran the new code as soon as the update returned. - The function as it was before the update (configuration, AWS-only settings, engine deployment) is kept as the prior revision, and Invoke of $LATEST runs it until the window closes. Published versions are immutable and unaffected. - On the real-runtime path a code update deploys to a fresh engine key (.update-), so the old deployment keeps serving. Deployments that no longer serve anything are removed at the next code update or on delete, while the function's engine reservation is held; engine calls still never run under m.mu, and every funcData read-modify-write stays under it. - The live engine key is persisted in snapshots; the prior revision is not, like the settle windows. - The version UpdateFunctionCode --publish cuts during the window now reports State Pending, StateReasonCode Creating and no LastUpdateStatus until the update completes, then Active/Successful, as real Lambda reports it. It shares the update's exact deadline through settle.Set.Window. Default mode (no --async-settle) is unchanged: updates complete at once and the code is redeployed in place under the function name. Part of #1665 --- config/engine.go | 5 +- internal/settle/settle.go | 10 + providers/aws/lambda/engine_ops.go | 115 ++++++-- providers/aws/lambda/lambda.go | 60 +++- providers/aws/lambda/lifecycle.go | 128 ++++++++ providers/aws/lambda/parity_1665_test.go | 337 ++++++++++++++++++++++ providers/aws/lambda/snapshot.go | 21 +- providers/aws/lambda/versions.go | 18 +- server/aws/lambda/parity_1665_sdk_test.go | 173 +++++++++++ 9 files changed, 817 insertions(+), 50 deletions(-) create mode 100644 providers/aws/lambda/parity_1665_test.go create mode 100644 server/aws/lambda/parity_1665_sdk_test.go diff --git a/config/engine.go b/config/engine.go index cbe03a588..09ea6b83a 100644 --- a/config/engine.go +++ b/config/engine.go @@ -107,7 +107,10 @@ type FunctionEngine interface { // FunctionDeployment describes the code and runtime contract a FunctionEngine // must be able to execute. type FunctionDeployment struct { - Name string // function name (the Invoke/Remove key) + // Name is the Invoke/Remove key: the function name. Under AsyncSettle an + // AWS Lambda code update deploys to ".update-" instead, so the + // previous deployment keeps serving invocations until the update completes. + Name string Runtime string // e.g. "python3.12", "nodejs20.x" Handler string // e.g. "main.handler" (file.function) Code []byte // deployment package (a .zip archive) diff --git a/internal/settle/settle.go b/internal/settle/settle.go index 015022d66..e460dd5d9 100644 --- a/internal/settle/settle.go +++ b/internal/settle/settle.go @@ -201,6 +201,16 @@ func (s *Set) Settled(id string, now time.Time) bool { return true } +// Window returns id's window, or the inactive zero Window when id has none. It +// lets a dependent resource share the exact deadline of the window it is tied to +// (for example a Lambda version cut during an update settles with that update). +func (s *Set) Window(id string) Window { + s.mu.RLock() + defer s.mu.RUnlock() + + return s.m[id] +} + // Clear drops id's window (on delete, or when a terminal state is reached and // the window is no longer needed). Idempotent. func (s *Set) Clear(id string) { diff --git a/providers/aws/lambda/engine_ops.go b/providers/aws/lambda/engine_ops.go index 3af23264c..3759b08aa 100644 --- a/providers/aws/lambda/engine_ops.go +++ b/providers/aws/lambda/engine_ops.go @@ -2,6 +2,7 @@ package lambda import ( "context" + "slices" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/services/serverless/driver" @@ -49,10 +50,13 @@ func (m *Mock) release(name string, token uint64) { } } -// removeDeployment tears down name's engine deployment on a cleanup path, -// where the caller has nothing better to do with a failure. -func (m *Mock) removeDeployment(ctx context.Context, name string) { - _ = funcengine.Remove(ctx, m.opts.FunctionEngine, name) +// removeDeployments tears down the engine deployments under keys on a cleanup +// path, where the caller has nothing better to do with a failure. Callers hold +// the function's engine reservation, not m.mu. +func (m *Mock) removeDeployments(ctx context.Context, keys []string) { + for _, key := range keys { + _ = funcengine.Remove(ctx, m.opts.FunctionEngine, key) + } } // updateInProgress is the ResourceConflictException Lambda returns for a @@ -97,7 +101,7 @@ func (m *Mock) deployNewFunction(ctx context.Context, cfg *driver.FunctionConfig if appeared { if backed { - m.removeDeployment(ctx, cfg.Name) + m.removeDeployments(ctx, []string{cfg.Name}) } return nil, cerrors.Newf(cerrors.AlreadyExists, "function %s already exists", cfg.Name) @@ -136,8 +140,12 @@ func (m *Mock) DeleteFunction(ctx context.Context, name string) error { defer m.release(name, token) - if err := funcengine.Remove(ctx, m.opts.FunctionEngine, name); err != nil { - return cerrors.Newf(cerrors.Internal, "remove function %s: %v", name, err) + // Besides the live deployment, a function updated under AsyncSettle may + // hold its prior revision's and retired deployments. + for _, key := range fd.engineKeys() { + if err := funcengine.Remove(ctx, m.opts.FunctionEngine, key); err != nil { + return cerrors.Newf(cerrors.Internal, "remove function %s: %v", name, err) + } } return nil @@ -154,64 +162,123 @@ func (m *Mock) UpdateFunction(ctx context.Context, name string, cfg driver.Funct // A code update re-deploys to the engine using the post-merge runtime and // handler, so the function runs the new code, not the stale deployment. - info, token, err := m.reserveCodeUpdate(ctx, name, cfg) + plan, err := m.reserveCodeUpdate(ctx, name, cfg) if err != nil { return nil, err } - defer m.release(name, token) + defer m.release(name, plan.token) backed, err := funcengine.Deploy(ctx, m.opts.FunctionEngine, &driver.FunctionConfig{ - Name: name, Runtime: info.Runtime, Handler: info.Handler, - Code: cfg.Code, Environment: info.Environment, Timeout: info.Timeout, + Name: plan.key, Runtime: plan.info.Runtime, Handler: plan.info.Handler, + Code: cfg.Code, Environment: plan.info.Environment, Timeout: plan.info.Timeout, }) + info, stale, err := m.finalizeCodeUpdate(ctx, name, cfg, plan, backed, err) + + // The reservation is still held, so no other engine call for name runs + // while the deployments that no longer serve anything are removed. + m.removeDeployments(ctx, stale) + + return info, err +} + +// finalizeCodeUpdate applies a code update's deploy result to the function as +// it is now, under m.mu. It returns the updated function and the engine keys +// to remove once m.mu is released: the deployments the function retired, or, +// when DeleteFunction ran during the deploy, every deployment the deleted +// function held plus the new one. +// +//nolint:gocritic // hugeParam: cfg is the by-value driver config. +func (m *Mock) finalizeCodeUpdate( + ctx context.Context, name string, cfg driver.FunctionConfig, plan *codeUpdatePlan, backed bool, deployErr error, +) (*driver.FunctionInfo, []string, error) { m.mu.Lock() + defer m.mu.Unlock() if _, ok := m.funcs.Get(name); !ok { - m.mu.Unlock() // DeleteFunction ran during the deploy and left the engine cleanup // to this call. - m.removeDeployment(ctx, name) + stale := plan.held + if !slices.Contains(stale, plan.key) { + stale = append(slices.Clone(stale), plan.key) + } - return nil, m.errFunctionNotFound(ctx, name, "") + return nil, stale, m.errFunctionNotFound(ctx, name, "") } - defer m.mu.Unlock() + if deployErr != nil { + // A failed deploy to a fresh AsyncSettle key may have left a partial + // deployment; one to the live key is the function's current code. + var partial []string + if !slices.Contains(plan.held, plan.key) { + partial = []string{plan.key} + } + return nil, partial, cerrors.Newf(cerrors.InvalidArgument, "deploy function %s: %v", name, deployErr) + } + + info, err := m.commitUpdate(ctx, name, cfg, &codeDeploy{backed: backed, key: plan.key}) if err != nil { - return nil, cerrors.Newf(cerrors.InvalidArgument, "deploy function %s: %v", name, err) + return nil, nil, err + } + + return info, m.takeRetired(name), nil +} + +// takeRetired clears and returns function name's retired engine keys, which the +// caller removes while it holds the function's engine reservation. Callers +// hold m.mu. +func (m *Mock) takeRetired(name string) []string { + fd, ok := m.funcs.Get(name) + if !ok || len(fd.retired) == 0 { + return nil } - return m.commitUpdate(ctx, name, cfg, &backed) + retired := fd.retired + fd.retired = nil + m.funcs.Set(name, fd) + + return retired +} + +// codeUpdatePlan is a reserved code update: the merged configuration to deploy, +// the engine key to deploy it to, the engine keys the function held when it +// was reserved, and the reservation token. +type codeUpdatePlan struct { + info driver.FunctionInfo + key string + held []string + token uint64 } // reserveCodeUpdate validates a code update against the current function and -// reserves the function for the engine deploy. It returns the merged -// configuration to deploy and the reservation token. +// reserves the function for the engine deploy. // //nolint:gocritic // hugeParam: cfg is the by-value driver config. func (m *Mock) reserveCodeUpdate( ctx context.Context, name string, cfg driver.FunctionConfig, -) (driver.FunctionInfo, uint64, error) { +) (*codeUpdatePlan, error) { m.mu.Lock() defer m.mu.Unlock() fd, ok := m.funcs.Get(name) if !ok { - return driver.FunctionInfo{}, 0, m.errFunctionNotFound(ctx, name, "") + return nil, m.errFunctionNotFound(ctx, name, "") } // Refused while Pending creation or while an update (an AsyncSettle window // or an engine deploy) is InProgress. if err := m.checkUpdatable(&fd); err != nil { - return driver.FunctionInfo{}, 0, err + return nil, err } info, err := m.mergeUpdate(fd.info, cfg) if err != nil { - return driver.FunctionInfo{}, 0, err + return nil, err } - return info, m.reserve(name), nil + return &codeUpdatePlan{ + info: info, key: m.updateEngineKey(&fd), held: fd.engineKeys(), token: m.reserve(name), + }, nil } diff --git a/providers/aws/lambda/lambda.go b/providers/aws/lambda/lambda.go index 5270e631e..8fb00515f 100644 --- a/providers/aws/lambda/lambda.go +++ b/providers/aws/lambda/lambda.go @@ -199,8 +199,14 @@ type versionData struct { awsConfig driver.AWSFunctionConfig // cutWhilePending marks the version CreateFunction(Publish=true) cut while // the function was still Pending creation; it reports Pending until creation - // completes. Every other published version is Active/Successful. + // completes. cutWhilePending bool + // updateWindow is the InProgress window of the update a Publish=true + // UpdateFunctionCode cut this version with. Real Lambda reports that version + // Pending (reason Creating) until the update completes. It is inactive for + // every other version, which is Active/Successful. Like the function's own + // settle windows it is not persisted. + updateWindow settle.Window } type aliasData struct { @@ -261,6 +267,20 @@ type funcData struct { // codeSigningConfigARN is the code signing config attached to the function // (PutFunctionCodeSigningConfig), or "" when none is. codeSigningConfigARN string + // engineName is the FunctionEngine key the function's current code is + // deployed under; "" means the function name. Under AsyncSettle a code update + // deploys to a fresh key, so the previous deployment keeps serving + // invocations until the update completes (see prior). + engineName string + // prior is the function as it was before its latest update under + // AsyncSettle. Invoke of $LATEST runs it while that update is InProgress, + // as real Lambda does. nil until such an update; not persisted, like the + // settle windows, so a restored function serves its current revision. + prior *priorRevision + // retired lists engine keys that no longer serve any invocation. They are + // removed at the function's next code update or on delete, the operations + // that hold the function's engine reservation. + retired []string } // Mock is an in-memory mock implementation of AWS Lambda. @@ -464,12 +484,21 @@ func (m *Mock) ListFunctions(_ context.Context) ([]driver.FunctionInfo, error) { return infos, nil } -// commitUpdate applies cfg to the stored function and writes it back. backed, -// when set, is the engine state after a code deploy. Callers hold mu. +// codeDeploy is the outcome of a code update's engine deploy: whether the +// function is engine-backed afterwards, and the engine key the code went to. +type codeDeploy struct { + backed bool + key string +} + +// commitUpdate applies cfg to the stored function and writes it back. deployed, +// when set, is the engine state after a code deploy. Under AsyncSettle the +// function as it was before is kept as fd.prior, which Invoke serves until the +// update completes. Callers hold mu. // //nolint:gocritic // hugeParam: cfg is the by-value driver config. func (m *Mock) commitUpdate( - ctx context.Context, name string, cfg driver.FunctionConfig, backed *bool, + ctx context.Context, name string, cfg driver.FunctionConfig, deployed *codeDeploy, ) (*driver.FunctionInfo, error) { fd, ok := m.funcs.Get(name) if !ok { @@ -480,7 +509,7 @@ func (m *Mock) commitUpdate( // creation or a previous update is InProgress (an AsyncSettle window or an // engine deploy), as real Lambda does. A code update was checked when it // reserved the name (reserveCodeUpdate) and is finalized here. - if backed == nil { + if deployed == nil { if err := m.checkUpdatable(&fd); err != nil { return nil, err } @@ -491,10 +520,14 @@ func (m *Mock) commitUpdate( return nil, err } + m.keepPrior(&fd) fd.info = info - if backed != nil { - fd.engineBacked = *backed + if deployed != nil { + fd.engineBacked = deployed.backed + if deployed.backed { + fd.engineName = engineNameFor(name, deployed.key) + } } m.funcs.Set(name, fd) @@ -557,6 +590,10 @@ func (m *Mock) Invoke(ctx context.Context, input driver.InvokeInput) (*driver.In return nil, err } + // While an update is InProgress, $LATEST still runs the code and + // configuration it had before the update. + m.servePrior(&fd, executedVersion) + // A DryRun validates the function, qualifier, and permissions but runs // nothing and emits no metrics: resolveQualifier above has already rejected // an unknown alias/version with ResourceNotFoundException, so a DryRun that @@ -610,7 +647,7 @@ func (m *Mock) runInvocation( h := m.resolveHandler(fd, input.FunctionName) if h == nil && fd.engineBacked { - return m.invokeEngine(ctx, input, dims, executedVersion, start) + return m.invokeEngine(ctx, input, fd.engineKey(), dims, executedVersion, start) } if h == nil { @@ -828,13 +865,14 @@ func functionNameFromARN(arn string) string { } // invokeEngine runs a function whose code was deployed to the configured -// FunctionEngine and records the same metrics as a real handler invocation. A +// FunctionEngine under engineKey and records the same metrics as a real handler invocation. A // handler that raised is reported via out.Error (HTTP stays 200), matching real // Lambda's X-Amz-Function-Error semantics. func (m *Mock) invokeEngine( - ctx context.Context, input driver.InvokeInput, dims map[string]string, executedVersion string, start time.Time, + ctx context.Context, input driver.InvokeInput, engineKey string, dims map[string]string, + executedVersion string, start time.Time, ) (*driver.InvokeOutput, error) { - out, err := funcengine.Invoke(ctx, m.opts.FunctionEngine, input.FunctionName, input.Payload) + out, err := funcengine.Invoke(ctx, m.opts.FunctionEngine, engineKey, input.Payload) duration := m.durationMillis(start) if err != nil { diff --git a/providers/aws/lambda/lifecycle.go b/providers/aws/lambda/lifecycle.go index 8dd63401b..b4d7eedba 100644 --- a/providers/aws/lambda/lifecycle.go +++ b/providers/aws/lambda/lifecycle.go @@ -2,6 +2,8 @@ package lambda import ( "context" + "maps" + "slices" cerrors "github.com/stackshy/cloudemu/v2/errors" "github.com/stackshy/cloudemu/v2/internal/idgen" @@ -139,3 +141,129 @@ func (m *Mock) checkInvocable(fd *funcData) error { return nil } + +// priorRevision is what Invoke of $LATEST runs while an update is InProgress: +// the function's configuration, engine deployment and AWS-only settings as they +// were before the update. Real Lambda documents that "invocations continue to +// use the function's previous code and configuration until the update +// completes". See +// https://docs.aws.amazon.com/lambda/latest/dg/functions-states.html +type priorRevision struct { + info driver.FunctionInfo + engineBacked bool + engineName string + awsConfig driver.AWSFunctionConfig +} + +// engineKey is the FunctionEngine key fd's current code is deployed under. +func (fd *funcData) engineKey() string { + if fd.engineName != "" { + return fd.engineName + } + + return fd.info.Name +} + +// engineNameFor is the funcData.engineName value for engine key key of function +// name: "" when the code is deployed under the function name itself. +func engineNameFor(name, key string) string { + if key == name { + return "" + } + + return key +} + +// updatesSettle reports whether an update opens an InProgress window, i.e. +// whether AsyncSettle is on. +func (m *Mock) updatesSettle() bool { + return m.opts.SettleDuration(settle.DefaultLambdaUpdateSettle) > 0 +} + +// updateEngineKey is the engine key a code update of fd deploys to. By default +// it replaces the current deployment in place. Under AsyncSettle it is a fresh +// key, so the current deployment keeps serving invocations until the update +// completes. The suffix uses '.', which a Lambda function name cannot contain, +// so the key cannot collide with another function's. +func (m *Mock) updateEngineKey(fd *funcData) string { + if !m.updatesSettle() { + return fd.engineKey() + } + + return fd.info.Name + ".update-" + newRevisionID() +} + +// keepPrior records fd as it is now as the revision Invoke serves until the +// update being committed completes. A no-op unless AsyncSettle is on. The +// previous prior's update has completed (an update is refused while another is +// InProgress), so its engine deployment is retired unless it is still the live +// one. Callers hold m.mu and write fd back. +func (m *Mock) keepPrior(fd *funcData) { + if !m.updatesSettle() { + return + } + + if p := fd.prior; p != nil { + fd.retired = retireEngineKey(fd.retired, p.engineKey(fd.info.Name), fd.engineKey()) + } + + info := fd.info + info.Environment = maps.Clone(info.Environment) + info.Tags = maps.Clone(info.Tags) + + fd.prior = &priorRevision{ + info: info, engineBacked: fd.engineBacked, engineName: fd.engineName, + awsConfig: cloneAWSConfig(&fd.awsConfig), + } +} + +// engineKey is the engine key the prior revision of function name runs under. +func (p *priorRevision) engineKey(name string) string { + if p.engineName != "" { + return p.engineName + } + + return name +} + +// retireEngineKey returns retired with key added, unless key is the live key or +// already listed. It never appends in place, so a reader holding an earlier +// funcData copy keeps its own slice. +func retireEngineKey(retired []string, key, live string) []string { + if key == live || slices.Contains(retired, key) { + return retired + } + + return append(slices.Clone(retired), key) +} + +// engineKeys lists every engine key fd holds a deployment under: the live one, +// the prior revision's and the retired ones, without duplicates. +func (fd *funcData) engineKeys() []string { + keys := []string{fd.engineKey()} + + if fd.prior != nil { + keys = append(keys, fd.prior.engineKey(fd.info.Name)) + } + + keys = append(keys, fd.retired...) + slices.Sort(keys) + + return slices.Compact(keys) +} + +// servePrior turns fd, a caller's copy, into what an invocation of +// executedVersion runs: for $LATEST while an update is InProgress, the code and +// configuration from before the update. Published versions are immutable and +// never affected. +func (m *Mock) servePrior(fd *funcData, executedVersion string) { + if executedVersion != latestVersion || fd.prior == nil || !m.isUpdating(fd.info.Name) { + return + } + + p := fd.prior + fd.info = p.info + fd.engineBacked = p.engineBacked + fd.engineName = p.engineName + fd.awsConfig = p.awsConfig +} diff --git a/providers/aws/lambda/parity_1665_test.go b/providers/aws/lambda/parity_1665_test.go new file mode 100644 index 000000000..5b1e7bf5a --- /dev/null +++ b/providers/aws/lambda/parity_1665_test.go @@ -0,0 +1,337 @@ +package lambda + +import ( + "context" + "encoding/json" + "fmt" + "slices" + "strings" + "sync" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// codeEngine is a FunctionEngine whose invocation returns the code deployed +// under the invoked key, so a test can tell which revision served a call. +type codeEngine struct { + mu sync.Mutex + deployed map[string]string +} + +func newCodeEngine() *codeEngine { + return &codeEngine{deployed: map[string]string{}} +} + +//nolint:gocritic // fn is the by-value DTO defined by the FunctionEngine contract +func (e *codeEngine) Deploy(_ context.Context, fn config.FunctionDeployment) error { + e.mu.Lock() + defer e.mu.Unlock() + + e.deployed[fn.Name] = string(fn.Code) + + return nil +} + +func (e *codeEngine) Invoke(_ context.Context, name string, _ []byte) (config.FunctionResult, error) { + e.mu.Lock() + defer e.mu.Unlock() + + code, ok := e.deployed[name] + if !ok { + return config.FunctionResult{}, fmt.Errorf("%q not deployed", name) + } + + return config.FunctionResult{Payload: []byte(code)}, nil +} + +func (e *codeEngine) Remove(_ context.Context, name string) error { + e.mu.Lock() + defer e.mu.Unlock() + + delete(e.deployed, name) + + return nil +} + +func (e *codeEngine) keys() []string { + e.mu.Lock() + defer e.mu.Unlock() + + keys := make([]string, 0, len(e.deployed)) + for k := range e.deployed { + keys = append(keys, k) + } + + slices.Sort(keys) + + return keys +} + +func newEngineSettleMock(eng config.FunctionEngine, settled bool) (*Mock, *config.FakeClock) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := []config.Option{config.WithClock(fc), config.WithRegion("us-east-1"), config.WithFunctionEngine(eng)} + + if settled { + opts = append(opts, config.WithAsyncSettle()) + } + + return New(config.NewOptions(opts...)), fc +} + +func invokedCode(t *testing.T, m *Mock, qualifier string) string { + t.Helper() + + out, err := m.Invoke(context.Background(), driver.InvokeInput{FunctionName: "real-fn", Qualifier: qualifier}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + + return string(out.Payload) +} + +// TestInvokeRunsPreviousCodeWhileUpdateInProgress: under AsyncSettle, Invoke of +// $LATEST keeps running the code from before an UpdateFunctionCode until the +// update completes, as real Lambda documents; then it runs the new code. +func TestInvokeRunsPreviousCodeWhileUpdateInProgress(t *testing.T) { + ctx := context.Background() + eng := newCodeEngine() + m, fc := newEngineSettleMock(eng, true) + + cfg := engineFuncConfig() + cfg.Code = []byte("v1") + + if _, err := m.CreateFunction(ctx, cfg); err != nil { + t.Fatal(err) + } + + fc.Advance(2 * time.Second) + + if _, err := m.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: []byte("v2")}); err != nil { + t.Fatal(err) + } + + if got := invokedCode(t, m, ""); got != "v1" { + t.Errorf("invoke during InProgress ran %q, want the previous code v1", got) + } + + if got := invokedCode(t, m, latestVersion); got != "v1" { + t.Errorf("invoke $LATEST during InProgress ran %q, want v1", got) + } + + fc.Advance(2 * time.Second) + + if got := invokedCode(t, m, ""); got != "v2" { + t.Errorf("invoke after the update completed ran %q, want v2", got) + } + + // The next update retires the original deployment: only the live and the + // prior revision's deployments remain. + if _, err := m.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: []byte("v3")}); err != nil { + t.Fatal(err) + } + + if got := invokedCode(t, m, ""); got != "v2" { + t.Errorf("invoke during the second update ran %q, want v2", got) + } + + if keys := eng.keys(); len(keys) != 2 || slices.Contains(keys, "real-fn") { + t.Errorf("engine deployments after the second update = %v, want the v2 and v3 keys only", keys) + } + + fc.Advance(2 * time.Second) + + if got := invokedCode(t, m, ""); got != "v3" { + t.Errorf("invoke after the second update ran %q, want v3", got) + } + + if err := m.DeleteFunction(ctx, "real-fn"); err != nil { + t.Fatal(err) + } + + if keys := eng.keys(); len(keys) != 0 { + t.Errorf("engine deployments after delete = %v, want none", keys) + } +} + +// TestInvokeRunsNewCodeImmediatelyByDefault: without AsyncSettle an update +// completes at once, so Invoke runs the new code right away, and the code is +// redeployed in place under the function name (the historical behavior). +func TestInvokeRunsNewCodeImmediatelyByDefault(t *testing.T) { + ctx := context.Background() + eng := newCodeEngine() + m, _ := newEngineSettleMock(eng, false) + + cfg := engineFuncConfig() + cfg.Code = []byte("v1") + + if _, err := m.CreateFunction(ctx, cfg); err != nil { + t.Fatal(err) + } + + if _, err := m.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: []byte("v2")}); err != nil { + t.Fatal(err) + } + + if got := invokedCode(t, m, ""); got != "v2" { + t.Errorf("invoke after update ran %q, want v2", got) + } + + if keys := eng.keys(); !slices.Equal(keys, []string{"real-fn"}) { + t.Errorf("engine deployments = %v, want [real-fn]", keys) + } +} + +// TestInvokeUsesPreviousConfigurationWhileUpdateInProgress: the previous +// configuration (including AWS-only settings such as the DeadLetterConfig an +// async failure routes to) is what an invocation of $LATEST sees during the +// window; a published version is immutable and is never swapped. +func TestInvokeUsesPreviousConfigurationWhileUpdateInProgress(t *testing.T) { + ctx := context.Background() + m, fc := newSettleMock() + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + if err := m.SetFunctionAWSConfig(ctx, "my-func", driver.AWSFunctionConfig{ + DeadLetterConfig: &driver.DeadLetterConfig{TargetArn: "arn:aws:sqs:us-east-1:123456789012:old"}, + }, true); err != nil { + t.Fatal(err) + } + + fc.Advance(2 * time.Second) + + if _, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Timeout: 42}); err != nil { + t.Fatal(err) + } + + if err := m.SetFunctionAWSConfig(ctx, "my-func", driver.AWSFunctionConfig{ + DeadLetterConfig: &driver.DeadLetterConfig{TargetArn: "arn:aws:sqs:us-east-1:123456789012:new"}, + }, false); err != nil { + t.Fatal(err) + } + + served := func(version string) funcData { + fd, _ := m.funcs.Get("my-func") + m.servePrior(&fd, version) + + return fd + } + + during := served(latestVersion) + if during.info.Timeout == 42 || !strings.HasSuffix(during.awsConfig.DeadLetterConfig.TargetArn, ":old") { + t.Errorf("$LATEST during InProgress serves Timeout %d / DLQ %s, want the previous ones", + during.info.Timeout, during.awsConfig.DeadLetterConfig.TargetArn) + } + + if v := served("1"); v.info.Timeout != 42 { + t.Errorf("a published version is swapped to the prior revision (Timeout %d)", v.info.Timeout) + } + + fc.Advance(2 * time.Second) + + after := served(latestVersion) + if after.info.Timeout != 42 || !strings.HasSuffix(after.awsConfig.DeadLetterConfig.TargetArn, ":new") { + t.Errorf("$LATEST after the update serves Timeout %d / DLQ %s, want the new ones", + after.info.Timeout, after.awsConfig.DeadLetterConfig.TargetArn) + } +} + +// TestEngineNameSurvivesSnapshot: the engine key a function's live code runs +// under is persisted, so a restored function still invokes its deployment; +// the prior revision is not (settle windows are not persisted). +func TestEngineNameSurvivesSnapshot(t *testing.T) { + ctx := context.Background() + eng := newCodeEngine() + m, fc := newEngineSettleMock(eng, true) + + cfg := engineFuncConfig() + cfg.Code = []byte("v1") + + if _, err := m.CreateFunction(ctx, cfg); err != nil { + t.Fatal(err) + } + + fc.Advance(2 * time.Second) + + if _, err := m.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: []byte("v2")}); err != nil { + t.Fatal(err) + } + + snap, err := m.Snapshot(ctx, false) + if err != nil { + t.Fatal(err) + } + + if !json.Valid(snap) || !strings.Contains(string(snap), `"engineName":"real-fn.update-`) { + t.Fatalf("snapshot does not carry the engine key: %s", snap) + } + + restored, _ := newEngineSettleMock(eng, true) + if err := restored.Restore(ctx, snap); err != nil { + t.Fatal(err) + } + + if got := invokedCode(t, restored, ""); got != "v2" { + t.Errorf("restored function ran %q, want v2", got) + } +} + +// TestVersionPublishedWithUpdateIsPendingUntilUpdateCompletes: the version +// UpdateFunctionCode(Publish=true) cuts reports State Pending, reason +// Creating and no LastUpdateStatus while the update is InProgress, as real +// Lambda does; it is Active/Successful once the update completes and stays so +// across later $LATEST updates. +func TestVersionPublishedWithUpdateIsPendingUntilUpdateCompletes(t *testing.T) { + ctx := context.Background() + m, fc := newSettleMock() + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + fc.Advance(2 * time.Second) + + if _, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Code: []byte("v2")}); err != nil { + t.Fatal(err) + } + + v, err := m.PublishVersionOnWrite(ctx, "my-func", "") + if err != nil { + t.Fatal(err) + } + + if v.State != statePending || v.StateReason != stateReasonCreating || + v.StateReasonCode != stateReasonCodeCreating || v.LastUpdateStatus != "" { + t.Fatalf("version cut by the update = %q/%q/%q/%q, want Pending/%s/Creating/", + v.State, v.StateReason, v.StateReasonCode, v.LastUpdateStatus, stateReasonCreating) + } + + fc.Advance(2 * time.Second) + + assertVersionActive := func(when string) { + t.Helper() + + vers, err := m.ListVersions(ctx, "my-func") + if err != nil { + t.Fatal(err) + } + + for _, got := range vers { + if got.Version == "1" && (got.State != stateActive || got.LastUpdateStatus != lastUpdateSuccessful) { + t.Errorf("version 1 %s = %q/%q, want Active/Successful", when, got.State, got.LastUpdateStatus) + } + } + } + + assertVersionActive("after the update completed") + + if _, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Timeout: 9}); err != nil { + t.Fatal(err) + } + + assertVersionActive("during a later $LATEST update") +} diff --git a/providers/aws/lambda/snapshot.go b/providers/aws/lambda/snapshot.go index 9d8d874ef..b036f568c 100644 --- a/providers/aws/lambda/snapshot.go +++ b/providers/aws/lambda/snapshot.go @@ -38,13 +38,16 @@ type lambdaSnapshot struct { // the mock does not retain, so republished code is still identified after a // restore; aliases are captured by name. type funcSnapshot struct { - Info driver.FunctionInfo `json:"info"` - EngineBacked bool `json:"engineBacked,omitempty"` - Versions []*versionSnapshot `json:"versions,omitempty"` - NextVersion int `json:"nextVersion,omitempty"` - Aliases map[string]driver.Alias `json:"aliases,omitempty"` - Concurrency *driver.ConcurrencyConfig `json:"concurrency,omitempty"` - Policies map[string]map[string]driver.PermissionStatement `json:"policies,omitempty"` + Info driver.FunctionInfo `json:"info"` + EngineBacked bool `json:"engineBacked,omitempty"` + // EngineName is the engine key the function's current code is deployed under + // when it is not the function name (a code update under AsyncSettle). + EngineName string `json:"engineName,omitempty"` + Versions []*versionSnapshot `json:"versions,omitempty"` + NextVersion int `json:"nextVersion,omitempty"` + Aliases map[string]driver.Alias `json:"aliases,omitempty"` + Concurrency *driver.ConcurrencyConfig `json:"concurrency,omitempty"` + Policies map[string]map[string]driver.PermissionStatement `json:"policies,omitempty"` // URLConfigs is the Function URL config per qualifier (see funcData.urlConfigs). URLConfigs map[string]*driver.FunctionURLConfig `json:"urlConfigs,omitempty"` // URLConfig is the legacy single-URL shape a snapshot taken before Function @@ -178,7 +181,7 @@ func snapshotLayerPermissions(ld *layerData) map[string]*layerVersionPolicySnaps func snapshotFunc(fd *funcData) *funcSnapshot { fs := &funcSnapshot{ - Info: fd.info, EngineBacked: fd.engineBacked, NextVersion: fd.nextVersion, + Info: fd.info, EngineBacked: fd.engineBacked, EngineName: fd.engineName, NextVersion: fd.nextVersion, Concurrency: fd.concurrency, Policies: fd.policies, URLConfigs: fd.urlConfigs, AWSConfig: fd.awsConfig, EventInvokeConfigs: fd.eventInvokeConfigs, ProvisionedConcurrencyConfigs: fd.provisionedConcurrencyConfigs, @@ -283,7 +286,7 @@ func restoreLayerData(ls *layerSnapshot) (*layerData, error) { func (m *Mock) restoreFunc(name string, fs *funcSnapshot) funcData { fd := funcData{ - info: fs.Info, engineBacked: fs.EngineBacked, nextVersion: fs.NextVersion, + info: fs.Info, engineBacked: fs.EngineBacked, engineName: fs.EngineName, nextVersion: fs.NextVersion, aliases: memstore.New[*aliasData](), concurrency: fs.Concurrency, policies: fs.Policies, urlConfigs: legacyURLConfigs(fs), awsConfig: fs.AWSConfig, eventInvokeConfigs: fs.EventInvokeConfigs, diff --git a/providers/aws/lambda/versions.go b/providers/aws/lambda/versions.go index 1afb31295..28b858a6c 100644 --- a/providers/aws/lambda/versions.go +++ b/providers/aws/lambda/versions.go @@ -90,6 +90,12 @@ func (m *Mock) publishVersion( // the function is still Pending; a standalone PublishVersion is refused then. cutWhilePending: !gate && m.isPending(functionName), } + + // Likewise only Publish=true on UpdateFunctionCode cuts a version while an + // update is InProgress. That version settles together with the update. + if !gate && m.isUpdating(functionName) { + vd.updateWindow = m.updateSettle.Window(functionName) + } fd.versions = append(fd.versions, vd) m.funcs.Set(functionName, fd) @@ -134,12 +140,14 @@ func (m *Mock) versionInfo(functionName string, v *versionData) driver.FunctionV } // observeVersion sets a published version's lifecycle. A published version is -// immutable, so an update of $LATEST (and its InProgress window) never reaches -// it: it is Active with LastUpdateStatus Successful. The one exception is the -// version CreateFunction(Publish=true) cut, which AWS reports Pending (reason -// Creating, no LastUpdateStatus) until the function's creation completes. +// immutable, so a later update of $LATEST (and its InProgress window) never +// reaches it: it is Active with LastUpdateStatus Successful. The exceptions are +// the versions a write cut with Publish=true: the one CreateFunction cut is +// Pending (reason Creating, no LastUpdateStatus) until the function's creation +// completes, and the one UpdateFunctionCode cut is Pending the same way until +// that update completes, as real Lambda reports them. func (m *Mock) observeVersion(functionName string, v *versionData, out *driver.FunctionVersion) { - if v.cutWhilePending && m.isPending(functionName) { + if (v.cutWhilePending && m.isPending(functionName)) || !v.updateWindow.Settled(m.opts.Clock.Now()) { out.State = statePending out.StateReason = stateReasonCreating out.StateReasonCode = stateReasonCodeCreating diff --git a/server/aws/lambda/parity_1665_sdk_test.go b/server/aws/lambda/parity_1665_sdk_test.go new file mode 100644 index 000000000..1e6d8e84d --- /dev/null +++ b/server/aws/lambda/parity_1665_sdk_test.go @@ -0,0 +1,173 @@ +package lambda_test + +import ( + "context" + "fmt" + "sync" + "testing" + "time" + + "github.com/aws/aws-sdk-go-v2/aws" + awslambda "github.com/aws/aws-sdk-go-v2/service/lambda" + lambdatypes "github.com/aws/aws-sdk-go-v2/service/lambda/types" + + "github.com/stackshy/cloudemu/v2/config" +) + +// echoCodeEngine is a FunctionEngine whose invocation returns the deployment +// package deployed under the invoked key, so a test sees which code ran. +type echoCodeEngine struct { + mu sync.Mutex + deployed map[string][]byte +} + +//nolint:gocritic // fn is the by-value DTO defined by the FunctionEngine contract +func (e *echoCodeEngine) Deploy(_ context.Context, fn config.FunctionDeployment) error { + e.mu.Lock() + defer e.mu.Unlock() + + e.deployed[fn.Name] = fn.Code + + return nil +} + +func (e *echoCodeEngine) Invoke(_ context.Context, name string, _ []byte) (config.FunctionResult, error) { + e.mu.Lock() + defer e.mu.Unlock() + + code, ok := e.deployed[name] + if !ok { + return config.FunctionResult{}, fmt.Errorf("%q not deployed", name) + } + + return config.FunctionResult{Payload: code}, nil +} + +func (e *echoCodeEngine) Remove(_ context.Context, name string) error { + e.mu.Lock() + defer e.mu.Unlock() + + delete(e.deployed, name) + + return nil +} + +func invokePayload(t *testing.T, c *awslambda.Client, name string) string { + t.Helper() + + out, err := c.Invoke(context.Background(), &awslambda.InvokeInput{FunctionName: aws.String(name)}) + if err != nil { + t.Fatalf("Invoke: %v", err) + } + + return string(out.Payload) +} + +// TestSDKInvokeRunsPreviousCodeWhileUpdateInProgress drives the real SDK: under +// AsyncSettle an Invoke right after UpdateFunctionCode still returns the old +// code's output while LastUpdateStatus is InProgress, and the new code's once +// the update is Successful. Without AsyncSettle the new code runs at once. +func TestSDKInvokeRunsPreviousCodeWhileUpdateInProgress(t *testing.T) { + for _, async := range []bool{true, false} { + t.Run(fmt.Sprintf("async=%v", async), func(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + opts := []config.Option{ + config.WithClock(fc), + config.WithFunctionEngine(&echoCodeEngine{deployed: map[string][]byte{}}), + } + + if async { + opts = append(opts, config.WithAsyncSettle()) + } + + c := newParityLambdaClient(t, opts...) + ctx := context.Background() + + createParityFunction(t, c, "swap", []byte(`"old"`)) + fc.Advance(2 * time.Second) + + upd, err := c.UpdateFunctionCode(ctx, &awslambda.UpdateFunctionCodeInput{ + FunctionName: aws.String("swap"), ZipFile: []byte(`"new"`), + }) + if err != nil { + t.Fatal(err) + } + + want := `"new"` + if async { + want = `"old"` + + if upd.LastUpdateStatus != lambdatypes.LastUpdateStatusInProgress { + t.Fatalf("LastUpdateStatus = %q, want InProgress", upd.LastUpdateStatus) + } + } + + if got := invokePayload(t, c, "swap"); got != want { + t.Errorf("invoke right after the update = %s, want %s", got, want) + } + + fc.Advance(2 * time.Second) + + if got := invokePayload(t, c, "swap"); got != `"new"` { + t.Errorf("invoke after the update completed = %s, want \"new\"", got) + } + }) + } +} + +// TestSDKUpdateFunctionCodePublishVersionIsPending: the version +// UpdateFunctionCode --publish cuts is Pending/Creating with no +// LastUpdateStatus while the update is InProgress (real Lambda's response and +// GetFunction --qualifier), and Active/Successful once it completes. +func TestSDKUpdateFunctionCodePublishVersionIsPending(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + c := newParityLambdaClient(t, config.WithClock(fc), config.WithAsyncSettle()) + ctx := context.Background() + + createParityFunction(t, c, "pubupd", []byte("v1")) + fc.Advance(2 * time.Second) + + upd, err := c.UpdateFunctionCode(ctx, &awslambda.UpdateFunctionCodeInput{ + FunctionName: aws.String("pubupd"), ZipFile: []byte("v2"), Publish: true, + }) + if err != nil { + t.Fatal(err) + } + + pending := versionSnapshot{ + lastModified: aws.ToString(upd.LastModified), state: string(lambdatypes.StatePending), + stateReasonCode: string(lambdatypes.StateReasonCodeCreating), + } + + got := snapshotFields(upd.LastModified, upd.State, upd.StateReasonCode, upd.LastUpdateStatus) + if aws.ToString(upd.Version) != "1" || got != pending { + t.Fatalf("UpdateFunctionCode --publish = %s %+v, want 1 %+v", aws.ToString(upd.Version), got, pending) + } + + if aws.ToString(upd.StateReason) != "The function is being created." { + t.Errorf("StateReason = %q, want The function is being created.", aws.ToString(upd.StateReason)) + } + + if got := getVersionSnapshot(t, c, "pubupd", "1"); got != pending { + t.Fatalf("version 1 while the update is InProgress = %+v, want %+v", got, pending) + } + + latest, err := c.GetFunctionConfiguration(ctx, &awslambda.GetFunctionConfigurationInput{FunctionName: aws.String("pubupd")}) + if err != nil { + t.Fatal(err) + } + + if latest.State != lambdatypes.StateActive || latest.LastUpdateStatus != lambdatypes.LastUpdateStatusInProgress { + t.Errorf("$LATEST = %q/%q, want Active/InProgress", latest.State, latest.LastUpdateStatus) + } + + fc.Advance(2 * time.Second) + + active := versionSnapshot{ + lastModified: pending.lastModified, state: string(lambdatypes.StateActive), + lastUpdateStatus: string(lambdatypes.LastUpdateStatusSuccessful), + } + if got := getVersionSnapshot(t, c, "pubupd", "1"); got != active { + t.Errorf("version 1 once the update completed = %+v, want %+v", got, active) + } +} From d0f99f262c2538bd0b2e5e0ec89a35a34cb77510 Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Sat, 24 Oct 2026 22:02:35 +0530 Subject: [PATCH 6/7] fix(aws-lambda): keep a deployment until the invocations that picked it finish Under AsyncSettle an invocation could pick a deployment that a concurrent code update then removed, and fail with ServiceException ("not deployed"): - Invoke reads an unlocked copy of the function but checked the shared settle set to decide whether to serve the prior revision, so a copy taken just before an update was committed could serve the revision that update retired. The prior revision now carries its update's InProgress window, and Invoke checks only that, so a copy is self-consistent. - A code update removed retired deployments while invocations on them could still be starting or running. An invocation now pins the deployment it runs on, re-checking after pinning that the function still serves it, and a code update removes a retired deployment only once its pins are released. Also moves the published version's lifecycle fields into setVersionLifecycle, which keeps publishVersion at cyclomatic complexity 10 and reads the update window once. --- providers/aws/lambda/engine_ops.go | 7 +- .../aws/lambda/invoke_update_race_test.go | 189 ++++++++++++++++++ providers/aws/lambda/lambda.go | 28 ++- providers/aws/lambda/lifecycle.go | 24 ++- providers/aws/lambda/pins.go | 105 ++++++++++ providers/aws/lambda/versions.go | 27 ++- 6 files changed, 356 insertions(+), 24 deletions(-) create mode 100644 providers/aws/lambda/invoke_update_race_test.go create mode 100644 providers/aws/lambda/pins.go diff --git a/providers/aws/lambda/engine_ops.go b/providers/aws/lambda/engine_ops.go index 3759b08aa..e4a6d1b58 100644 --- a/providers/aws/lambda/engine_ops.go +++ b/providers/aws/lambda/engine_ops.go @@ -51,10 +51,13 @@ func (m *Mock) release(name string, token uint64) { } // removeDeployments tears down the engine deployments under keys on a cleanup -// path, where the caller has nothing better to do with a failure. Callers hold -// the function's engine reservation, not m.mu. +// path, where the caller has nothing better to do with a failure. The function +// no longer serves any of keys, so no new invocation pins them; each is removed +// once the invocations already running on it finish. Callers hold the +// function's engine reservation, not m.mu. func (m *Mock) removeDeployments(ctx context.Context, keys []string) { for _, key := range keys { + m.pins.wait(key) _ = funcengine.Remove(ctx, m.opts.FunctionEngine, key) } } diff --git a/providers/aws/lambda/invoke_update_race_test.go b/providers/aws/lambda/invoke_update_race_test.go new file mode 100644 index 000000000..9833caea8 --- /dev/null +++ b/providers/aws/lambda/invoke_update_race_test.go @@ -0,0 +1,189 @@ +package lambda + +import ( + "context" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/stackshy/cloudemu/v2/config" + "github.com/stackshy/cloudemu/v2/services/serverless/driver" +) + +// TestConcurrentInvokeDuringCodeUpdatesNeverHitsRemovedDeployment: under +// AsyncSettle, invocations of $LATEST that race code updates must always find +// the deployment they picked. A code update retires and removes the deployment +// from two updates back, which an invocation holding an older copy of the +// function, or one still running, may have picked. +func TestConcurrentInvokeDuringCodeUpdatesNeverHitsRemovedDeployment(t *testing.T) { + ctx := context.Background() + eng := newCodeEngine() + m, fc := newEngineSettleMock(eng, true) + + cfg := engineFuncConfig() + cfg.Code = []byte("v0") + + if _, err := m.CreateFunction(ctx, cfg); err != nil { + t.Fatal(err) + } + + fc.Advance(2 * time.Second) + + const ( + invokers = 4 + updates = 1500 + ) + + var ( + failures atomic.Int64 + firstErr atomic.Value + wg sync.WaitGroup + ) + + stop := make(chan struct{}) + + for range invokers { + wg.Add(1) + + go func() { + defer wg.Done() + + for { + select { + case <-stop: + return + default: + } + + if _, err := m.Invoke(ctx, driver.InvokeInput{FunctionName: "real-fn"}); err != nil { + failures.Add(1) + firstErr.CompareAndSwap(nil, err.Error()) + } + } + }() + } + + for i := range updates { + if _, err := m.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: fmt.Appendf(nil, "v%d", i+1)}); err != nil { + t.Errorf("UpdateFunction %d: %v", i+1, err) + + break + } + + fc.Advance(2 * time.Second) + } + + close(stop) + wg.Wait() + + if n := failures.Load(); n > 0 { + t.Fatalf("%d invocations failed during code updates; first: %v", n, firstErr.Load()) + } + + // Only the live deployment and the prior revision's remain. + if keys := eng.keys(); len(keys) > 2 { + t.Errorf("engine holds %d deployments after the updates, want at most 2: %v", len(keys), keys) + } +} + +// blockingEngine is a codeEngine whose Invoke of the key in hold waits until +// release is closed and only then looks the deployment up, like an engine whose +// invocation is still running when the deployment is torn down. +type blockingEngine struct { + *codeEngine + + hold string + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (e *blockingEngine) Invoke(ctx context.Context, name string, event []byte) (config.FunctionResult, error) { + if name == e.hold { + e.once.Do(func() { close(e.entered) }) + <-e.release + } + + return e.codeEngine.Invoke(ctx, name, event) +} + +// TestCodeUpdateWaitsForRunningInvocationOfRetiredDeployment: an invocation +// still running on the prior revision's deployment when the next code update +// retires it completes normally; the update removes that deployment only after +// the invocation finishes. +func TestCodeUpdateWaitsForRunningInvocationOfRetiredDeployment(t *testing.T) { + ctx := context.Background() + eng := &blockingEngine{ + codeEngine: newCodeEngine(), hold: "real-fn", + entered: make(chan struct{}), release: make(chan struct{}), + } + m, fc := newEngineSettleMock(eng, true) + + cfg := engineFuncConfig() + cfg.Code = []byte("v1") + + if _, err := m.CreateFunction(ctx, cfg); err != nil { + t.Fatal(err) + } + + fc.Advance(2 * time.Second) + + if _, err := m.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: []byte("v2")}); err != nil { + t.Fatal(err) + } + + // The update is InProgress, so this invocation runs v1 under "real-fn". + type result struct { + out *driver.InvokeOutput + err error + } + + invoked := make(chan result, 1) + + go func() { + out, err := m.Invoke(ctx, driver.InvokeInput{FunctionName: "real-fn"}) + invoked <- result{out, err} + }() + + <-eng.entered + fc.Advance(2 * time.Second) + + // The next code update retires "real-fn" while the invocation still runs. + updated := make(chan error, 1) + + go func() { + _, err := m.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: []byte("v3")}) + updated <- err + }() + + select { + case err := <-updated: + // Without waiting, the update has already removed the deployment. + close(eng.release) + t.Fatalf("code update returned (err %v) while an invocation of the deployment it retires was running", err) + case <-time.After(200 * time.Millisecond): + } + + close(eng.release) + + res := <-invoked + if res.err != nil { + t.Fatalf("invocation running on the retired deployment failed: %v", res.err) + } + + if got := string(res.out.Payload); got != "v1" { + t.Errorf("invocation ran %q, want v1", got) + } + + if err := <-updated; err != nil { + t.Fatalf("UpdateFunction v3: %v", err) + } + + for _, k := range eng.keys() { + if k == "real-fn" { + t.Errorf("retired deployment %q still deployed after the update: %v", k, eng.keys()) + } + } +} diff --git a/providers/aws/lambda/lambda.go b/providers/aws/lambda/lambda.go index 8fb00515f..f00738cde 100644 --- a/providers/aws/lambda/lambda.go +++ b/providers/aws/lambda/lambda.go @@ -329,6 +329,9 @@ type Mock struct { // per function name. It backs reserved-concurrency enforcement on Invoke. inflightMu sync.Mutex inflight map[string]int + // pins counts the invocations running on each engine deployment, so a code + // update removes a deployment it retired only once they have finished. + pins *deployPins } // SetMonitoring sets the monitoring backend for auto-metric generation. @@ -380,6 +383,7 @@ func New(opts *config.Options) *Mock { createSettle: settle.NewSet(), updateSettle: settle.NewSet(), cscs: memstore.New[driver.CodeSigningConfig](), + pins: newDeployPins(), } } @@ -520,7 +524,9 @@ func (m *Mock) commitUpdate( return nil, err } - m.keepPrior(&fd) + // The update's InProgress window opens now; the prior revision serves + // $LATEST invocations until it closes. + m.keepPrior(&fd, m.beginUpdateSettle(name)) fd.info = info if deployed != nil { @@ -531,7 +537,6 @@ func (m *Mock) commitUpdate( } m.funcs.Set(name, fd) - m.beginUpdateSettle(name) result := info m.observe(&result) @@ -647,7 +652,7 @@ func (m *Mock) runInvocation( h := m.resolveHandler(fd, input.FunctionName) if h == nil && fd.engineBacked { - return m.invokeEngine(ctx, input, fd.engineKey(), dims, executedVersion, start) + return m.invokeEngine(ctx, input, fd, dims, executedVersion, start) } if h == nil { @@ -865,13 +870,22 @@ func functionNameFromARN(arn string) string { } // invokeEngine runs a function whose code was deployed to the configured -// FunctionEngine under engineKey and records the same metrics as a real handler invocation. A -// handler that raised is reported via out.Error (HTTP stays 200), matching real -// Lambda's X-Amz-Function-Error semantics. +// FunctionEngine and records the same metrics as a real handler invocation. fd +// is the invocation's copy of the function; the deployment it runs is pinned +// for the call (see pinDeployment). A handler that raised is reported via +// out.Error (HTTP stays 200), matching real Lambda's X-Amz-Function-Error +// semantics. func (m *Mock) invokeEngine( - ctx context.Context, input driver.InvokeInput, engineKey string, dims map[string]string, + ctx context.Context, input driver.InvokeInput, fd *funcData, dims map[string]string, executedVersion string, start time.Time, ) (*driver.InvokeOutput, error) { + engineKey, err := m.pinDeployment(ctx, fd, executedVersion) + if err != nil { + return nil, err + } + + defer m.pins.unpin(engineKey) + out, err := funcengine.Invoke(ctx, m.opts.FunctionEngine, engineKey, input.Payload) duration := m.durationMillis(start) diff --git a/providers/aws/lambda/lifecycle.go b/providers/aws/lambda/lifecycle.go index b4d7eedba..7d3cfe921 100644 --- a/providers/aws/lambda/lifecycle.go +++ b/providers/aws/lambda/lifecycle.go @@ -58,10 +58,13 @@ func (m *Mock) beginCreateSettle(name string) { // beginUpdateSettle opens the LastUpdateStatus=InProgress window an // UpdateFunctionCode/UpdateFunctionConfiguration reports until the update -// completes. A no-op unless AsyncSettle is on. -func (m *Mock) beginUpdateSettle(name string) { +// completes, and returns it. A no-op returning the inactive window unless +// AsyncSettle is on. +func (m *Mock) beginUpdateSettle(name string) settle.Window { m.updateSettle.Begin(name, lastUpdateInProgress, m.opts.Clock.Now(), m.opts.SettleDuration(settle.DefaultLambdaUpdateSettle)) + + return m.updateSettle.Window(name) } // clearSettle drops a deleted function's settle windows. @@ -153,6 +156,11 @@ type priorRevision struct { engineBacked bool engineName string awsConfig driver.AWSFunctionConfig + // window is the InProgress window of the update that replaced this + // revision. Keeping it here, rather than asking the shared settle set, + // makes a funcData copy self-consistent: a copy taken before a later update + // was committed never serves a revision that update retired. + window settle.Window } // engineKey is the FunctionEngine key fd's current code is deployed under. @@ -197,8 +205,9 @@ func (m *Mock) updateEngineKey(fd *funcData) string { // update being committed completes. A no-op unless AsyncSettle is on. The // previous prior's update has completed (an update is refused while another is // InProgress), so its engine deployment is retired unless it is still the live -// one. Callers hold m.mu and write fd back. -func (m *Mock) keepPrior(fd *funcData) { +// one. window is the InProgress window of the update being committed. Callers +// hold m.mu and write fd back. +func (m *Mock) keepPrior(fd *funcData, window settle.Window) { if !m.updatesSettle() { return } @@ -213,7 +222,7 @@ func (m *Mock) keepPrior(fd *funcData) { fd.prior = &priorRevision{ info: info, engineBacked: fd.engineBacked, engineName: fd.engineName, - awsConfig: cloneAWSConfig(&fd.awsConfig), + awsConfig: cloneAWSConfig(&fd.awsConfig), window: window, } } @@ -255,9 +264,10 @@ func (fd *funcData) engineKeys() []string { // servePrior turns fd, a caller's copy, into what an invocation of // executedVersion runs: for $LATEST while an update is InProgress, the code and // configuration from before the update. Published versions are immutable and -// never affected. +// never affected. It reads only fd, so a copy taken at any moment serves a +// revision that copy still holds. func (m *Mock) servePrior(fd *funcData, executedVersion string) { - if executedVersion != latestVersion || fd.prior == nil || !m.isUpdating(fd.info.Name) { + if executedVersion != latestVersion || fd.prior == nil || fd.prior.window.Settled(m.opts.Clock.Now()) { return } diff --git a/providers/aws/lambda/pins.go b/providers/aws/lambda/pins.go new file mode 100644 index 000000000..485797c30 --- /dev/null +++ b/providers/aws/lambda/pins.go @@ -0,0 +1,105 @@ +package lambda + +import ( + "context" + "sync" + + cerrors "github.com/stackshy/cloudemu/v2/errors" +) + +// maxPinAttempts bounds how often pinDeployment re-reads a function whose +// deployment a concurrent code update retired. One retry normally suffices: a +// fresh copy only serves deployments no update has retired yet. +const maxPinAttempts = 8 + +// deployPins counts the invocations running on each engine deployment, keyed by +// engine key, so a code update can wait for them before it removes a +// deployment it retired. +type deployPins struct { + mu sync.Mutex + idle *sync.Cond + count map[string]int +} + +func newDeployPins() *deployPins { + p := &deployPins{count: map[string]int{}} + p.idle = sync.NewCond(&p.mu) + + return p +} + +// pin records an invocation starting on key. +func (p *deployPins) pin(key string) { + p.mu.Lock() + defer p.mu.Unlock() + + p.count[key]++ +} + +// unpin records an invocation on key finishing. +func (p *deployPins) unpin(key string) { + p.mu.Lock() + defer p.mu.Unlock() + + p.count[key]-- + if p.count[key] <= 0 { + delete(p.count, key) + p.idle.Broadcast() + } +} + +// wait blocks until no invocation is running on key. +func (p *deployPins) wait(key string) { + p.mu.Lock() + defer p.mu.Unlock() + + for p.count[key] > 0 { + p.idle.Wait() + } +} + +// servesEngineKey reports whether an invocation of fd can still run under key: +// it is the live deployment or the prior revision's. A retired key is neither. +func (fd *funcData) servesEngineKey(key string) bool { + if key == fd.engineKey() { + return true + } + + return fd.prior != nil && key == fd.prior.engineKey(fd.info.Name) +} + +// pinDeployment pins the engine deployment the invocation fd describes runs +// under and returns its key; the caller unpins it when the invocation ends. +// +// fd is an unlocked copy, so a code update may have retired its deployment +// since it was taken. The function is re-read after pinning: if the stored +// function no longer serves the key, fd is refreshed (keeping executedVersion) +// and the pin is retried. A code update removes a retired deployment only after +// it stored the function without it and pins.wait returned, so a pin that +// passes this check holds a deployment that stays until the invocation ends. +func (m *Mock) pinDeployment(ctx context.Context, fd *funcData, executedVersion string) (string, error) { + name := fd.info.Name + + for range maxPinAttempts { + key := fd.engineKey() + m.pins.pin(key) + + cur, ok := m.funcs.Get(name) + if !ok { + m.pins.unpin(key) + + return "", m.errFunctionNotFound(ctx, name, "") + } + + if cur.servesEngineKey(key) { + return key, nil + } + + m.pins.unpin(key) + + *fd = cur + m.servePrior(fd, executedVersion) + } + + return "", cerrors.Newf(cerrors.Internal, "invoke function %s: deployment changed during the invocation", name) +} diff --git a/providers/aws/lambda/versions.go b/providers/aws/lambda/versions.go index 28b858a6c..096473d31 100644 --- a/providers/aws/lambda/versions.go +++ b/providers/aws/lambda/versions.go @@ -86,22 +86,33 @@ func (m *Mock) publishVersion( createdAt: m.opts.Clock.Now().UTC().Format(time.RFC3339), description: verDesc, awsConfig: cloneAWSConfig(&fd.awsConfig), - // Only the atomic Publish=true on CreateFunction can cut a version while - // the function is still Pending; a standalone PublishVersion is refused then. - cutWhilePending: !gate && m.isPending(functionName), } + m.setVersionLifecycle(vd, functionName, gate) - // Likewise only Publish=true on UpdateFunctionCode cuts a version while an - // update is InProgress. That version settles together with the update. - if !gate && m.isUpdating(functionName) { - vd.updateWindow = m.updateSettle.Window(functionName) - } fd.versions = append(fd.versions, vd) m.funcs.Set(functionName, fd) return m.versionResult(functionName, vd, ""), nil } +// setVersionLifecycle records whether vd, being cut from function functionName, +// reports Pending until a write completes. Only a version a write cut with +// Publish=true (gate false) can: the one CreateFunction cut while the function +// is still Pending, and the one UpdateFunctionCode cut while its update is +// InProgress, which settles together with that update. A standalone +// PublishVersion is refused in both states. +func (m *Mock) setVersionLifecycle(vd *versionData, functionName string, gate bool) { + if gate { + return + } + + vd.cutWhilePending = m.isPending(functionName) + + if w := m.updateSettle.Window(functionName); !w.Settled(m.opts.Clock.Now()) { + vd.updateWindow = w + } +} + // versionResult renders a published version's driver.FunctionVersion, used both // when a fresh version is cut and when a no-change PublishVersion returns the // existing version. A non-empty description overrides the stored version From 461b6068a1c67fa4bdb2cf3055b76869b6852979 Mon Sep 17 00:00:00 2001 From: Satyam Trivedi Date: Sat, 24 Oct 2026 22:03:58 +0530 Subject: [PATCH 7/7] fix(aws-lambda): report the update reason and keep retired deployments across a restore - $LATEST reports LastUpdateStatusReason "The function is being created." and LastUpdateStatusReasonCode "Creating" while an update is InProgress, as real Lambda does, and omits them once the update is Successful. A published version never carries them. - A snapshot records the prior revision's deployment as retired (the prior revision itself is not persisted), and persists the retired list, so after a restore the next code update or DeleteFunction removes those deployments instead of leaking them. - DeleteFunction with an unknown non-numeric qualifier stays ResourceNotFoundException but now uses the same "Function not found: " text as the other not-found errors. --- providers/aws/lambda/alias_notfound_test.go | 11 ++- providers/aws/lambda/lifecycle.go | 24 ++++- providers/aws/lambda/parity_1665_test.go | 100 ++++++++++++++++++++ providers/aws/lambda/snapshot.go | 10 ++ providers/aws/lambda/versions.go | 7 +- server/aws/lambda/handler.go | 6 ++ server/aws/lambda/parity_1665_sdk_test.go | 56 +++++++++++ server/aws/lambda/types.go | 7 ++ services/serverless/driver/driver.go | 6 ++ 9 files changed, 221 insertions(+), 6 deletions(-) diff --git a/providers/aws/lambda/alias_notfound_test.go b/providers/aws/lambda/alias_notfound_test.go index 1db567594..9a03536f1 100644 --- a/providers/aws/lambda/alias_notfound_test.go +++ b/providers/aws/lambda/alias_notfound_test.go @@ -58,7 +58,14 @@ func TestAliasAndVersionNotFoundParity(t *testing.T) { t.Errorf("DeleteVersion(missing function) err = %v, want NotFound", err) } - if err := m.DeleteVersion(ctx, "my-func", "not-an-alias"); !cerrors.IsNotFound(err) { - t.Errorf("DeleteVersion(unknown non-numeric qualifier) err = %v, want NotFound (unchanged)", err) + err = m.DeleteVersion(ctx, "my-func", "not-an-alias") + if !cerrors.IsNotFound(err) { + t.Fatalf("DeleteVersion(unknown non-numeric qualifier) err = %v, want NotFound", err) + } + + // Worded like the other not-found errors: the qualified function ARN. + if got, want := cerrors.Message(err), + "Function not found: arn:aws:lambda:us-east-1:123456789012:function:my-func:not-an-alias"; got != want { + t.Errorf("DeleteVersion(unknown non-numeric qualifier) message = %q, want %q", got, want) } } diff --git a/providers/aws/lambda/lifecycle.go b/providers/aws/lambda/lifecycle.go index 7d3cfe921..9f4281f52 100644 --- a/providers/aws/lambda/lifecycle.go +++ b/providers/aws/lambda/lifecycle.go @@ -86,8 +86,14 @@ func (m *Mock) isUpdating(name string) bool { // observe overlays the function's lifecycle onto info: State Pending with the // Creating reason (and no LastUpdateStatus, which AWS first sets once creation // completes) while creation settles; otherwise the stored State with -// LastUpdateStatus InProgress while an update settles, else Successful. +// LastUpdateStatus InProgress while an update settles, else Successful. While +// an update is InProgress, real Lambda gives LastUpdateStatusReason "The +// function is being created." and LastUpdateStatusReasonCode "Creating", the +// same text as a Pending function's StateReason. func (m *Mock) observe(info *driver.FunctionInfo) { + info.LastUpdateStatusReason = "" + info.LastUpdateStatusReasonCode = "" + if m.isPending(info.Name) { info.State = statePending info.StateReason = stateReasonCreating @@ -100,6 +106,11 @@ func (m *Mock) observe(info *driver.FunctionInfo) { info.StateReason = "" info.StateReasonCode = "" info.LastUpdateStatus = m.updateSettle.State(info.Name, m.opts.Clock.Now(), lastUpdateSuccessful) + + if info.LastUpdateStatus == lastUpdateInProgress { + info.LastUpdateStatusReason = stateReasonCreating + info.LastUpdateStatusReasonCode = stateReasonCodeCreating + } } // errPending is the ResourceConflictException real Lambda returns for an @@ -246,6 +257,17 @@ func retireEngineKey(retired []string, key, live string) []string { return append(slices.Clone(retired), key) } +// retiredWithPrior is fd.retired with the prior revision's engine key added, +// for a snapshot: the prior revision is not persisted, so after a restore its +// deployment no longer serves anything and must be removed like a retired one. +func (fd *funcData) retiredWithPrior() []string { + if fd.prior == nil { + return fd.retired + } + + return retireEngineKey(fd.retired, fd.prior.engineKey(fd.info.Name), fd.engineKey()) +} + // engineKeys lists every engine key fd holds a deployment under: the live one, // the prior revision's and the retired ones, without duplicates. func (fd *funcData) engineKeys() []string { diff --git a/providers/aws/lambda/parity_1665_test.go b/providers/aws/lambda/parity_1665_test.go index 5b1e7bf5a..82dac93d1 100644 --- a/providers/aws/lambda/parity_1665_test.go +++ b/providers/aws/lambda/parity_1665_test.go @@ -335,3 +335,103 @@ func TestVersionPublishedWithUpdateIsPendingUntilUpdateCompletes(t *testing.T) { assertVersionActive("during a later $LATEST update") } + +// TestRetiredDeploymentsAreRemovedAfterRestore: the prior revision is not +// persisted, so a snapshot records its deployment as retired; after a restore +// the next code update removes it, and DeleteFunction removes every deployment. +func TestRetiredDeploymentsAreRemovedAfterRestore(t *testing.T) { + ctx := context.Background() + eng := newCodeEngine() + m, fc := newEngineSettleMock(eng, true) + + cfg := engineFuncConfig() + cfg.Code = []byte("v1") + + if _, err := m.CreateFunction(ctx, cfg); err != nil { + t.Fatal(err) + } + + fc.Advance(2 * time.Second) + + if _, err := m.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: []byte("v2")}); err != nil { + t.Fatal(err) + } + + snap, err := m.Snapshot(ctx, false) + if err != nil { + t.Fatal(err) + } + + restored, rfc := newEngineSettleMock(eng, true) + if err := restored.Restore(ctx, snap); err != nil { + t.Fatal(err) + } + + rfc.Advance(2 * time.Second) + + if _, err := restored.UpdateFunction(ctx, "real-fn", driver.FunctionConfig{Code: []byte("v3")}); err != nil { + t.Fatal(err) + } + + // v1's deployment ("real-fn") was the prior revision when the snapshot was + // taken; only v2's (now the prior) and v3's remain. + if keys := eng.keys(); len(keys) != 2 || slices.Contains(keys, "real-fn") { + t.Errorf("engine deployments after the update = %v, want only v2's and v3's", keys) + } + + if err := restored.DeleteFunction(ctx, "real-fn"); err != nil { + t.Fatal(err) + } + + if keys := eng.keys(); len(keys) != 0 { + t.Errorf("engine deployments after delete = %v, want none", keys) + } +} + +// TestLatestInProgressReportsUpdateReason: while an update is InProgress, +// $LATEST reports LastUpdateStatusReason "The function is being created." and +// LastUpdateStatusReasonCode "Creating", as real Lambda does; both clear once +// the update is Successful. +func TestLatestInProgressReportsUpdateReason(t *testing.T) { + ctx := context.Background() + m, fc := newSettleMock() + + if _, err := m.CreateFunction(ctx, defaultFuncConfig()); err != nil { + t.Fatal(err) + } + + created, err := m.GetFunction(ctx, "my-func") + if err != nil { + t.Fatal(err) + } + + if created.LastUpdateStatusReason != "" || created.LastUpdateStatusReasonCode != "" { + t.Errorf("Pending function reports update reason %q/%q, want none", + created.LastUpdateStatusReason, created.LastUpdateStatusReasonCode) + } + + fc.Advance(2 * time.Second) + + upd, err := m.UpdateFunction(ctx, "my-func", driver.FunctionConfig{Description: "d2"}) + if err != nil { + t.Fatal(err) + } + + if upd.LastUpdateStatusReason != stateReasonCreating || upd.LastUpdateStatusReasonCode != stateReasonCodeCreating { + t.Errorf("InProgress update reason = %q/%q, want %q/%q", upd.LastUpdateStatusReason, + upd.LastUpdateStatusReasonCode, stateReasonCreating, stateReasonCodeCreating) + } + + fc.Advance(2 * time.Second) + + done, err := m.GetFunction(ctx, "my-func") + if err != nil { + t.Fatal(err) + } + + if done.LastUpdateStatus != lastUpdateSuccessful || done.LastUpdateStatusReason != "" || + done.LastUpdateStatusReasonCode != "" { + t.Errorf("after the update = %q %q/%q, want Successful with no reason", done.LastUpdateStatus, + done.LastUpdateStatusReason, done.LastUpdateStatusReasonCode) + } +} diff --git a/providers/aws/lambda/snapshot.go b/providers/aws/lambda/snapshot.go index b036f568c..0eac1887d 100644 --- a/providers/aws/lambda/snapshot.go +++ b/providers/aws/lambda/snapshot.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "maps" + "slices" "strconv" "github.com/stackshy/cloudemu/v2/internal/memstore" @@ -48,6 +49,13 @@ type funcSnapshot struct { Aliases map[string]driver.Alias `json:"aliases,omitempty"` Concurrency *driver.ConcurrencyConfig `json:"concurrency,omitempty"` Policies map[string]map[string]driver.PermissionStatement `json:"policies,omitempty"` + + // RetiredEngineNames are engine keys the function still holds a deployment + // under but no longer serves (see funcData.retired). The prior revision's + // key is folded in, since the prior revision itself is not persisted. They + // are removed at the next code update or on delete. + RetiredEngineNames []string `json:"retiredEngineNames,omitempty"` + // URLConfigs is the Function URL config per qualifier (see funcData.urlConfigs). URLConfigs map[string]*driver.FunctionURLConfig `json:"urlConfigs,omitempty"` // URLConfig is the legacy single-URL shape a snapshot taken before Function @@ -186,6 +194,7 @@ func snapshotFunc(fd *funcData) *funcSnapshot { AWSConfig: fd.awsConfig, EventInvokeConfigs: fd.eventInvokeConfigs, ProvisionedConcurrencyConfigs: fd.provisionedConcurrencyConfigs, CodeSigningConfigARN: fd.codeSigningConfigARN, + RetiredEngineNames: fd.retiredWithPrior(), } for _, v := range fd.versions { @@ -287,6 +296,7 @@ func restoreLayerData(ls *layerSnapshot) (*layerData, error) { func (m *Mock) restoreFunc(name string, fs *funcSnapshot) funcData { fd := funcData{ info: fs.Info, engineBacked: fs.EngineBacked, engineName: fs.EngineName, nextVersion: fs.NextVersion, + retired: slices.Clone(fs.RetiredEngineNames), aliases: memstore.New[*aliasData](), concurrency: fs.Concurrency, policies: fs.Policies, urlConfigs: legacyURLConfigs(fs), awsConfig: fs.AWSConfig, eventInvokeConfigs: fs.EventInvokeConfigs, diff --git a/providers/aws/lambda/versions.go b/providers/aws/lambda/versions.go index 096473d31..a054ad590 100644 --- a/providers/aws/lambda/versions.go +++ b/providers/aws/lambda/versions.go @@ -262,13 +262,14 @@ func (m *Mock) DeleteVersion(ctx context.Context, name, qualifier string) error if !m.versionExists(&fd, qualifier) { // Real Lambda treats deleting a numbered version the function does not // have as a no-op success: version-scoped DeleteFunction is idempotent - // while the function exists. Any other unknown qualifier keeps the - // emulator's not-found error (real behavior for it is unverified). + // while the function exists. Any other unknown qualifier stays + // ResourceNotFoundException (real behavior for it is unverified), worded + // like the emulator's other not-found errors: the qualified function ARN. if isVersionQualifier(qualifier) { return nil } - return cerrors.Newf(cerrors.NotFound, "function version %s not found", qualifier) + return m.errFunctionNotFound(ctx, name, qualifier) } if alias := aliasReferencing(&fd, qualifier); alias != "" { diff --git a/server/aws/lambda/handler.go b/server/aws/lambda/handler.go index 5d293aa31..de934fe54 100644 --- a/server/aws/lambda/handler.go +++ b/server/aws/lambda/handler.go @@ -1696,6 +1696,9 @@ func applyVersionLifecycle(cfg *functionConfiguration, ver *sdrv.FunctionVersion cfg.StateReason = ver.StateReason cfg.StateReasonCode = ver.StateReasonCode cfg.LastUpdateStatus = ver.LastUpdateStatus + // The $LATEST update reason never applies to a published version. + cfg.LastUpdateStatusReason = "" + cfg.LastUpdateStatusReasonCode = "" if cfg.State == "" { cfg.State = stateActive @@ -1728,6 +1731,9 @@ func toConfiguration(info *sdrv.FunctionInfo, awsCfg *sdrv.AWSFunctionConfig) fu CodeSize: info.CodeSize, Version: version, RevisionID: info.RevisionID, + + LastUpdateStatusReason: info.LastUpdateStatusReason, + LastUpdateStatusReasonCode: info.LastUpdateStatusReasonCode, } if len(info.Environment) > 0 { diff --git a/server/aws/lambda/parity_1665_sdk_test.go b/server/aws/lambda/parity_1665_sdk_test.go index 1e6d8e84d..c77694f7f 100644 --- a/server/aws/lambda/parity_1665_sdk_test.go +++ b/server/aws/lambda/parity_1665_sdk_test.go @@ -148,6 +148,11 @@ func TestSDKUpdateFunctionCodePublishVersionIsPending(t *testing.T) { t.Errorf("StateReason = %q, want The function is being created.", aws.ToString(upd.StateReason)) } + if upd.LastUpdateStatusReason != nil || upd.LastUpdateStatusReasonCode != "" { + t.Errorf("version LastUpdateStatusReason = %q/%q, want none", + aws.ToString(upd.LastUpdateStatusReason), upd.LastUpdateStatusReasonCode) + } + if got := getVersionSnapshot(t, c, "pubupd", "1"); got != pending { t.Fatalf("version 1 while the update is InProgress = %+v, want %+v", got, pending) } @@ -171,3 +176,54 @@ func TestSDKUpdateFunctionCodePublishVersionIsPending(t *testing.T) { t.Errorf("version 1 once the update completed = %+v, want %+v", got, active) } } + +// TestSDKLatestInProgressReportsUpdateReason: while an update of $LATEST is +// InProgress, real Lambda reports LastUpdateStatusReason "The function is being +// created." and LastUpdateStatusReasonCode "Creating"; both are omitted once +// the update is Successful. +func TestSDKLatestInProgressReportsUpdateReason(t *testing.T) { + fc := config.NewFakeClock(time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)) + c := newParityLambdaClient(t, config.WithClock(fc), config.WithAsyncSettle()) + ctx := context.Background() + + createParityFunction(t, c, "reason", []byte("v1")) + fc.Advance(2 * time.Second) + + upd, err := c.UpdateFunctionCode(ctx, &awslambda.UpdateFunctionCodeInput{ + FunctionName: aws.String("reason"), ZipFile: []byte("v2"), + }) + if err != nil { + t.Fatal(err) + } + + get := func() *awslambda.GetFunctionConfigurationOutput { + t.Helper() + + out, err := c.GetFunctionConfiguration(ctx, &awslambda.GetFunctionConfigurationInput{FunctionName: aws.String("reason")}) + if err != nil { + t.Fatal(err) + } + + return out + } + + inProgress := get() + + for name, got := range map[string][2]string{ + "UpdateFunctionCode": {aws.ToString(upd.LastUpdateStatusReason), string(upd.LastUpdateStatusReasonCode)}, + "GetFunctionConfiguration": {aws.ToString(inProgress.LastUpdateStatusReason), string(inProgress.LastUpdateStatusReasonCode)}, + } { + if got != [2]string{"The function is being created.", "Creating"} { + t.Errorf("%s LastUpdateStatusReason/Code while InProgress = %q, want The function is being created./Creating", name, got) + } + } + + fc.Advance(2 * time.Second) + + done := get() + if done.LastUpdateStatus != lambdatypes.LastUpdateStatusSuccessful || + done.LastUpdateStatusReason != nil || done.LastUpdateStatusReasonCode != "" { + t.Errorf("after the update = %q %q/%q, want Successful with no reason", done.LastUpdateStatus, + aws.ToString(done.LastUpdateStatusReason), done.LastUpdateStatusReasonCode) + } +} diff --git a/server/aws/lambda/types.go b/server/aws/lambda/types.go index bdc6c4e5d..f93f87e76 100644 --- a/server/aws/lambda/types.go +++ b/server/aws/lambda/types.go @@ -68,6 +68,13 @@ type functionConfiguration struct { VpcConfig *vpcConfigEnvelope `json:"VpcConfig,omitempty"` DeadLetterConfig *deadLetterConfigEnvelope `json:"DeadLetterConfig,omitempty"` TracingConfig *tracingConfigEnvelope `json:"TracingConfig,omitempty"` + + // LastUpdateStatusReason / LastUpdateStatusReasonCode explain an InProgress + // update ("The function is being created." / "Creating"); omitted once it + // is Successful. + LastUpdateStatusReason string `json:"LastUpdateStatusReason,omitempty"` + LastUpdateStatusReasonCode string `json:"LastUpdateStatusReasonCode,omitempty"` + // Architectures echoes the function's instruction set (["x86_64"] or // ["arm64"]); EphemeralStorage its /tmp size; Layers the imported layer // versions. All default when the function was created without them. diff --git a/services/serverless/driver/driver.go b/services/serverless/driver/driver.go index f957ebb73..ed2f26f08 100644 --- a/services/serverless/driver/driver.go +++ b/services/serverless/driver/driver.go @@ -362,6 +362,12 @@ type FunctionInfo struct { // Successful when creation completes). A backend that does not model the // update lifecycle leaves it empty. LastUpdateStatus string + // LastUpdateStatusReason / LastUpdateStatusReasonCode explain a + // LastUpdateStatus that is not Successful (AWS Lambda reports "The function + // is being created." / "Creating" while an update is InProgress). Empty + // otherwise. + LastUpdateStatusReason string + LastUpdateStatusReasonCode string } // InvokeInput configures a function invocation.