diff --git a/pkg/project/provider/aws.go b/pkg/project/provider/aws.go index d4843ccaf3..26c6b8792b 100644 --- a/pkg/project/provider/aws.go +++ b/pkg/project/provider/aws.go @@ -281,6 +281,88 @@ type AwsBootstrapData struct { type bootstrapStep = func(ctx context.Context, cfg aws.Config, data *AwsBootstrapData) error +const ( + lambdaCodeAssetLifecycleRuleID = "SstLambdaCodeAssetVersions" + legacyFunctionAssetLifecycleRuleID = "SstLegacyFunctionAssetVersions" + lambdaCodeAssetLifecyclePrefix = "lambda/" + legacyFunctionAssetLifecyclePrefix = "assets/" + lambdaCodeAssetNoncurrentRetainDays = 1 + lambdaCodeAssetRetainedNoncurrent = 1 +) + +func configureAssetBucketCodeVersioning(ctx context.Context, cfg aws.Config, data *AwsBootstrapData) error { + s3Client := s3.NewFromConfig(cfg) + + slog.Info("enabling versioning for asset bucket", "name", data.Asset) + _, err := s3Client.PutBucketVersioning(ctx, &s3.PutBucketVersioningInput{ + Bucket: aws.String(data.Asset), + VersioningConfiguration: &s3types.VersioningConfiguration{ + Status: s3types.BucketVersioningStatusEnabled, + }, + }) + if err != nil { + return err + } + + var existing []s3types.LifecycleRule + lifecycle, err := s3Client.GetBucketLifecycleConfiguration(ctx, &s3.GetBucketLifecycleConfigurationInput{ + Bucket: aws.String(data.Asset), + }) + if err != nil { + var apiErr smithy.APIError + if !errors.As(err, &apiErr) || apiErr.ErrorCode() != "NoSuchLifecycleConfiguration" { + return err + } + } else { + existing = lifecycle.Rules + } + + rules := make([]s3types.LifecycleRule, 0, len(existing)+2) + for _, rule := range existing { + switch aws.ToString(rule.ID) { + case lambdaCodeAssetLifecycleRuleID, legacyFunctionAssetLifecycleRuleID: + continue + default: + rules = append(rules, rule) + } + } + rules = append(rules, + s3types.LifecycleRule{ + ID: aws.String(lambdaCodeAssetLifecycleRuleID), + Status: s3types.ExpirationStatusEnabled, + Filter: &s3types.LifecycleRuleFilterMemberPrefix{ + Value: lambdaCodeAssetLifecyclePrefix, + }, + NoncurrentVersionExpiration: &s3types.NoncurrentVersionExpiration{ + NoncurrentDays: aws.Int32(lambdaCodeAssetNoncurrentRetainDays), + NewerNoncurrentVersions: aws.Int32(lambdaCodeAssetRetainedNoncurrent), + }, + }, + s3types.LifecycleRule{ + ID: aws.String(legacyFunctionAssetLifecycleRuleID), + Status: s3types.ExpirationStatusEnabled, + Filter: &s3types.LifecycleRuleFilterMemberPrefix{ + Value: legacyFunctionAssetLifecyclePrefix, + }, + Expiration: &s3types.LifecycleExpiration{ + ExpiredObjectDeleteMarker: aws.Bool(true), + }, + NoncurrentVersionExpiration: &s3types.NoncurrentVersionExpiration{ + NoncurrentDays: aws.Int32(lambdaCodeAssetNoncurrentRetainDays), + }, + }, + ) + + slog.Info("configuring asset bucket lifecycle", "name", data.Asset) + _, err = s3Client.PutBucketLifecycleConfiguration(ctx, &s3.PutBucketLifecycleConfigurationInput{ + Bucket: aws.String(data.Asset), + LifecycleConfiguration: &s3types.BucketLifecycleConfiguration{ + Rules: rules, + }, + }) + return err +} + // never change these, only append more steps var steps = []bootstrapStep{ // Step: create the bootstrap bucket @@ -530,6 +612,11 @@ var steps = []bootstrapStep{ func(ctx context.Context, cfg aws.Config, data *AwsBootstrapData) error { return nil }, + + // Step: version lambda code assets so Lambda updates can track object versions + func(ctx context.Context, cfg aws.Config, data *AwsBootstrapData) error { + return configureAssetBucketCodeVersioning(ctx, cfg, data) + }, } type AwsHome struct { diff --git a/platform/src/components/aws/function.ts b/platform/src/components/aws/function.ts index 45280fd2d5..eec88b8ca7 100644 --- a/platform/src/components/aws/function.ts +++ b/platform/src/components/aws/function.ts @@ -60,6 +60,30 @@ import { import { KvRoutesUpdate } from "./providers/kv-routes-update.js"; import { KvKeys } from "./providers/kv-keys.js"; +const lambdaCodeAssetPrefix = "lambda"; +const useVersionedLambdaCodeAssets = + process.env.SST_LAMBDA_CODE_ASSET_VERSIONING === "1" || + process.env.SST_LAMBDA_CODE_ASSET_VERSIONING === "true"; + +function assetKeySegment(value: string) { + return encodeURIComponent(value); +} + +function lambdaCodeAssetKey(name: string) { + const keyHash = crypto + .createHash("sha256") + .update([$app.name, $app.stage, name].join(":")) + .digest("hex") + .slice(0, 16); + return [ + lambdaCodeAssetPrefix, + assetKeySegment($app.name), + assetKeySegment($app.stage), + `${assetKeySegment(name)}-${keyHash}`, + "code.zip", + ].join("/"); +} + /** * Helper type to define function ARN type */ @@ -2549,9 +2573,14 @@ export class Function extends Component implements Link.Linkable { { key: dev ? `assets/dev-bridge-code-${hashValue}.zip` - : interpolate`assets/${name}-code-${hashValue}.zip`, + : useVersionedLambdaCodeAssets + ? lambdaCodeAssetKey(name) + : interpolate`assets/${name}-code-${hashValue}.zip`, bucket: assetBucket, source: new asset.FileArchive(zipPath), + ...(!dev && useVersionedLambdaCodeAssets + ? { sourceHash: hashValue } + : {}), }, dev ? { parent: rootStackResource, provider: opts?.provider } @@ -2667,6 +2696,9 @@ export class Function extends Component implements Link.Linkable { packageType: "Zip", s3Bucket: zipAsset!.bucket, s3Key: zipAsset!.key, + ...(dev || !useVersionedLambdaCodeAssets + ? {} + : { s3ObjectVersion: zipAsset!.versionId }), handler: unsecret(handler), runtime: runtime.apply((v) => v === "go" || v === "rust" ? "provided.al2023" : v,