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 fa40c8347..e460dd5d9 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 @@ -198,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/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/alias_notfound_test.go b/providers/aws/lambda/alias_notfound_test.go new file mode 100644 index 000000000..9a03536f1 --- /dev/null +++ b/providers/aws/lambda/alias_notfound_test.go @@ -0,0 +1,71 @@ +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) + } + + 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/aliases.go b/providers/aws/lambda/aliases.go index 01555c05d..f4b0dc940 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,18 +56,19 @@ 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) 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,17 +122,22 @@ 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 { +// +// 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() 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) { - return cerrors.Newf(cerrors.NotFound, "alias %s not found", aliasName) + return nil } fd.aliases.Delete(aliasName) @@ -166,15 +172,16 @@ 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) 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() @@ -185,10 +192,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..e4a6d1b58 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,16 @@ 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. 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) + } } // updateInProgress is the ResourceConflictException Lambda returns for a @@ -90,19 +97,21 @@ 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() 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) } result := info + m.observe(&result) return &result, nil } @@ -118,10 +127,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() @@ -133,8 +143,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 @@ -146,65 +160,128 @@ 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) + 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, cerrors.Newf(cerrors.NotFound, "function %s not found", 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 m.commitUpdate(name, cfg, &backed) + 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 + } + + 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(name string, cfg driver.FunctionConfig) (driver.FunctionInfo, uint64, error) { +func (m *Mock) reserveCodeUpdate( + ctx context.Context, name string, cfg driver.FunctionConfig, +) (*codeUpdatePlan, 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 nil, 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 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/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/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 7361c6bb1..f00738cde 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,26 @@ 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 + // cutWhilePending marks the version CreateFunction(Publish=true) cut while + // the function was still Pending creation; it reports Pending until creation + // 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 { @@ -243,6 +264,23 @@ 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 + // 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. @@ -277,10 +315,23 @@ 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 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. @@ -328,6 +379,11 @@ 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](), + pins: newDeployPins(), } } @@ -360,7 +416,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 +461,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,24 +480,43 @@ 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 } -// 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(name string, cfg driver.FunctionConfig, backed *bool) (*driver.FunctionInfo, error) { +func (m *Mock) commitUpdate( + ctx context.Context, name string, cfg driver.FunctionConfig, deployed *codeDeploy, +) (*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 deployed == nil { + if err := m.checkUpdatable(&fd); err != nil { + return nil, err + } } info, err := m.mergeUpdate(fd.info, cfg) @@ -448,15 +524,22 @@ func (m *Mock) commitUpdate(name string, cfg driver.FunctionConfig, backed *bool return nil, err } + // 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 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) result := info + m.observe(&result) return &result, nil } @@ -498,14 +581,24 @@ 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, "") } - executedVersion, err := m.resolveQualifier(&fd, input.Qualifier, input.Payload) + // 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(ctx, &fd, input.Qualifier, input.Payload) if err != nil { 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 @@ -559,7 +652,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, dims, executedVersion, start) } if h == nil { @@ -571,14 +664,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 +683,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 +730,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 +743,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 @@ -771,13 +870,23 @@ 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 -// 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, dims map[string]string, executedVersion string, start time.Time, + ctx context.Context, input driver.InvokeInput, fd *funcData, dims map[string]string, + executedVersion string, start time.Time, ) (*driver.InvokeOutput, error) { - out, err := funcengine.Invoke(ctx, m.opts.FunctionEngine, input.FunctionName, input.Payload) + 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) if err != nil { @@ -791,14 +900,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/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/lifecycle.go b/providers/aws/lambda/lifecycle.go new file mode 100644 index 000000000..9f4281f52 --- /dev/null +++ b/providers/aws/lambda/lifecycle.go @@ -0,0 +1,301 @@ +package lambda + +import ( + "context" + "maps" + "slices" + + 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, 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. +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. 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 + info.StateReasonCode = stateReasonCodeCreating + info.LastUpdateStatus = "" + + return + } + + 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 +// 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 +} + +// 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 + // 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. +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. 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 + } + + 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), window: window, + } +} + +// 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) +} + +// 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 { + 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. 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 || fd.prior.window.Settled(m.opts.Clock.Now()) { + 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_1518_test.go b/providers/aws/lambda/parity_1518_test.go new file mode 100644 index 000000000..3e005ad92 --- /dev/null +++ b/providers/aws/lambda/parity_1518_test.go @@ -0,0 +1,501 @@ +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) + } +} + +// 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/parity_1665_test.go b/providers/aws/lambda/parity_1665_test.go new file mode 100644 index 000000000..82dac93d1 --- /dev/null +++ b/providers/aws/lambda/parity_1665_test.go @@ -0,0 +1,437 @@ +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") +} + +// 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/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/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..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" @@ -29,6 +30,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 @@ -36,13 +39,23 @@ 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"` + + // 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 @@ -58,6 +71,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 +84,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 +154,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) } @@ -159,16 +189,20 @@ 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, + CodeSigningConfigARN: fd.codeSigningConfigARN, + RetiredEngineNames: fd.retiredWithPrior(), } 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 +232,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 +253,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 } @@ -252,11 +295,13 @@ 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, + retired: slices.Clone(fs.RetiredEngineNames), aliases: memstore.New[*aliasData](), concurrency: fs.Concurrency, 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 +311,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..a054ad590 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) } @@ -37,40 +64,77 @@ func (m *Mock) PublishVersion(_ context.Context, functionName, description strin // 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 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), } + m.setVersionLifecycle(vd, functionName, gate) + fd.versions = append(fd.versions, vd) m.funcs.Set(functionName, fd) - return versionResult(functionName, vd, description), nil + 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. description overrides the stored version description, matching -// PublishVersion's Description parameter. -func versionResult(functionName string, v *versionData, description string) *driver.FunctionVersion { - return &driver.FunctionVersion{ +// existing version. A non-empty description overrides the stored version +// description, matching PublishVersion's Description parameter. +func (m *Mock) versionResult(functionName string, v *versionData, description string) *driver.FunctionVersion { + out := m.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, 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: description, + Description: v.description, CodeSHA256: v.codeSHA, + CodeSize: v.codeSize, RevisionID: v.revisionID, CreatedAt: v.createdAt, Runtime: v.config.Runtime, @@ -78,14 +142,39 @@ 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), } + + m.observeVersion(functionName, v, &out) + + return out +} + +// observeVersion sets a published version's lifecycle. A published version is +// 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)) || !v.updateWindow.Settled(m.opts.Clock.Now()) { + out.State = statePending + out.StateReason = stateReasonCreating + out.StateReasonCode = stateReasonCodeCreating + + return + } + + out.State = stateActive + out.LastUpdateStatus = lastUpdateSuccessful } // 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,49 +182,68 @@ 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, m.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 // 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. -func (m *Mock) DeleteVersion(_ context.Context, name, qualifier string) error { +// "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() 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 { @@ -152,7 +260,16 @@ func (m *Mock) DeleteVersion(_ context.Context, name, qualifier string) error { } if !m.versionExists(&fd, qualifier) { - return cerrors.Newf(cerrors.NotFound, "function version %s not found", 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 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 m.errFunctionNotFound(ctx, name, qualifier) } if alias := aliasReferencing(&fd, qualifier); alias != "" { 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/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/classify.go b/server/aws/lambda/classify.go index 3f1e06d84..f780d790a 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. @@ -186,8 +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) + } + + if op, a, ok := classifyCodeSigningOrAccount(r, path); ok { + return op, a } if name, ok := concurrencyFunctionName(path); ok { @@ -200,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), "/") @@ -389,7 +422,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..452eceeea 100644 --- a/server/aws/lambda/code_signing.go +++ b/server/aws/lambda/code_signing.go @@ -1,10 +1,100 @@ 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" + +// 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 +// 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 +121,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 +137,303 @@ 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{bodyFunctionName: name, bodyCodeSigningConfigArn: 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 { + // 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 + } + + writeJSON(w, http.StatusOK, map[string]string{ + bodyFunctionName: name, bodyCodeSigningConfigArn: 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{bodyCodeSigningConfig: 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{bodyCodeSigningConfig: 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{bodyCodeSigningConfig: 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{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 eabc1a1cc..de934fe54 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,26 @@ 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" + +// 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" // 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 +220,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,7 +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) || - 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. @@ -280,12 +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 - } - - h.serveTags(w, r, op, a.name) + h.serveTagsRoute(w, r, op, &a) case routeEventSourceMappings: h.serveEventSourceMappings(w, r, op, a.item) case routeLayers: @@ -294,17 +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 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 { @@ -400,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") } @@ -790,7 +865,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 +890,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 @@ -985,20 +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 + } + + cfg, err := h.createFunctionConfig(r.Context(), &req) + if err != nil { + writeErr(w, err) return } - if err := h.checkCreateRequest(&req); err != nil { + 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)) +} + +// 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 + 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, @@ -1013,44 +1140,52 @@ 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 + return cfg, nil +} + +// 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 } - awsCfg := h.applyAWSConfig(r.Context(), req.FunctionName, h.createAWSConfig(&req), true) + csm, ok := h.fn.(codeSigningManager) + if !ok { + return true + } - // 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 - } + err := csm.PutFunctionCodeSigningConfig(r.Context(), name, arn) + if err == nil { + return true + } - writeJSON(w, http.StatusCreated, cfg) + // 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) - return + if h.checkCodeSigningConfigExists(w, r, arn) { + writeErr(w, err) } - writeJSON(w, http.StatusCreated, toConfiguration(info, awsCfg)) + return false } // checkCreateRequest resolves the FunctionName of a CreateFunction request to @@ -1142,12 +1277,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 +1437,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 +1445,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 @@ -1399,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 } @@ -1431,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 @@ -1445,6 +1618,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. @@ -1469,9 +1648,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 @@ -1483,16 +1665,47 @@ 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. 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 + // The $LATEST update reason never applies to a published version. + cfg.LastUpdateStatusReason = "" + cfg.LastUpdateStatusReasonCode = "" + + if cfg.State == "" { + cfg.State = stateActive + cfg.LastUpdateStatus = lastUpdateStatusSuccessful + } +} + func toConfiguration(info *sdrv.FunctionInfo, awsCfg *sdrv.AWSFunctionConfig) functionConfiguration { version := info.Version if version == "" { @@ -1510,12 +1723,17 @@ 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, Version: version, RevisionID: info.RevisionID, + + LastUpdateStatusReason: info.LastUpdateStatusReason, + LastUpdateStatusReasonCode: info.LastUpdateStatusReasonCode, } if len(info.Environment) > 0 { @@ -1567,6 +1785,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..9c715a415 100644 --- a/server/aws/lambda/iam_actions.go +++ b/server/aws/lambda/iam_actions.go @@ -146,6 +146,24 @@ func (c *checkSet) requestTags(tags map[string]string) { c.setList(condTagKeys, keys) } +// 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 + } +} + // resourceTags sets aws:ResourceTag/* from the tags of the function name. func (c *checkSet) resourceTags(name string) { tagger, ok := c.h.fn.(functionTagger) @@ -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..eb6a96c6c --- /dev/null +++ b/server/aws/lambda/parity_1518_sdk_test.go @@ -0,0 +1,634 @@ +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" + lambdaprov "github.com/stackshy/cloudemu/v2/providers/aws/lambda" + 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) + } +} + +// 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/server/aws/lambda/parity_1665_sdk_test.go b/server/aws/lambda/parity_1665_sdk_test.go new file mode 100644 index 000000000..c77694f7f --- /dev/null +++ b/server/aws/lambda/parity_1665_sdk_test.go @@ -0,0 +1,229 @@ +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 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) + } + + 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) + } +} + +// 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/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 diff --git a/server/aws/lambda/types.go b/server/aws/lambda/types.go index 67ccc419c..f93f87e76 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"` @@ -64,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. @@ -227,6 +238,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..ed2f26f08 100644 --- a/services/serverless/driver/driver.go +++ b/services/serverless/driver/driver.go @@ -20,6 +20,22 @@ 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 + // 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, @@ -335,6 +351,23 @@ 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 + // 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. @@ -363,6 +396,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 +471,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 +}