diff --git a/README.md b/README.md index 36cde61..4febcbf 100644 --- a/README.md +++ b/README.md @@ -504,6 +504,50 @@ Edge functions require two preconditions: Otherwise, it works as usual. +### Lambda Function URLs support + +lambroll can deploy [Lambda function URLs](https://docs.aws.amazon.com/lambda/latest/dg/lambda-urls.html). + +`lambroll deploy --function-url=function_url.json` deploys a function URL after the function deploied. + +When you want to deploy a public (without authentication) function URL, `function_url.json` is shown below. + +```json +{ + "Config": { + "AuthType": "NONE" + } +} +``` + +When you want to deploy a private (requires AWS IAM authentication) function URL, `function_url.json` is shown below. + +```json +{ + "Config": { + "AuthType": "AWS_IAM" + }, + "Permissions": [ + { + "Principal": "0123456789012" + }, + { + "PrincipalOrgID": "o-123456789", + "Principal": "*" + } + ] +} +``` + +- `Config` maps to [CreateFunctionUrlConfigInput](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/lambda#CreateFunctionUrlConfigInput) in AWS SDK Go v2. + - `Config.AuthType` must be `AWS_IAM` or `NONE`. + - `Config.Qualifer` is optional. +- `Permissions` is optional. + - If `Permissions` is not defined and `AuthType` is `NONE`, `Principal` is set to `*` automatically. + - When `AuthType` is `AWS_IAM`, you must define `Permissions` to specify allowed principals. + - Each elements of `Permissons` maps to [AddPermissionInput](https://pkg.go.dev/github.com/aws/aws-sdk-go-v2/service/lambda#AddPermissionInput) in AWS SDK Go v2. +- `function_url.jsonnet` is also supported like `function.jsonnet`. + ## LICENSE MIT License diff --git a/deploy.go b/deploy.go index 4f5eade..896494e 100644 --- a/deploy.go +++ b/deploy.go @@ -23,6 +23,8 @@ type DeployOption struct { DryRun bool `help:"dry run" default:"false"` SkipArchive bool `help:"skip to create zip archive. requires Code.S3Bucket and Code.S3Key in function definition" default:"false"` KeepVersions int `help:"Number of latest versions to keep. Older versions will be deleted. (Optional value: default 0)." default:"0"` + FunctionURL string `help:"path to function-url definiton" default:""` + SkipFunction bool `help:"skip to deploy a function. deploy function-url only" default:"false"` ExcludeFileOption } @@ -78,6 +80,22 @@ func (app *App) Deploy(ctx context.Context, opt *DeployOption) error { return fmt.Errorf("failed to load function: %w", err) } + deployFunctionURL := func(context.Context) error { return nil } + if opt.FunctionURL != "" { + deployFunctionURL = func(ctx context.Context) error { + fc, err := app.loadFunctionUrl(opt.FunctionURL, *fn.FunctionName) + if err != nil { + return fmt.Errorf("failed to load function url config: %w", err) + } + return app.deployFunctionURL(ctx, fc) + } + } + + if opt.SkipFunction { + // skip to deploy a function. deploy function-url only + return deployFunctionURL(ctx) + } + log.Printf("[info] starting deploy function %s", *fn.FunctionName) if current, err := app.lambda.GetFunction(ctx, &lambda.GetFunctionInput{ FunctionName: fn.FunctionName, @@ -183,6 +201,11 @@ func (app *App) Deploy(ctx context.Context, opt *DeployOption) error { if opt.KeepVersions > 0 { // Ignore zero-value. return app.deleteVersions(ctx, *fn.FunctionName, opt.KeepVersions) } + + if err := deployFunctionURL(ctx); err != nil { + return err + } + return nil } diff --git a/diff.go b/diff.go index 4ee3370..5be3aeb 100644 --- a/diff.go +++ b/diff.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "errors" "fmt" "io" "log" @@ -22,10 +23,11 @@ import ( // DiffOption represents options for Diff() type DiffOption struct { - Src string `help:"function zip archive or src dir" default:"."` - CodeSha256 bool `help:"diff of code sha256" default:"false"` - Unified bool `help:"unified diff" default:"true" negatable:"" short:"u"` - Qualifier string `help:"compare with" default:"$LATEST"` + Src string `help:"function zip archive or src dir" default:"."` + CodeSha256 bool `help:"diff of code sha256" default:"false"` + Unified bool `help:"unified diff" default:"true" negatable:"" short:"u"` + Qualifier *string `help:"compare with"` + FunctionURL string `help:"path to function-url definiton" default:""` ExcludeFileOption } @@ -51,7 +53,7 @@ func (app *App) Diff(ctx context.Context, opt *DiffOption) error { var packageType types.PackageType if res, err := app.lambda.GetFunction(ctx, &lambda.GetFunctionInput{ FunctionName: &name, - Qualifier: &opt.Qualifier, + Qualifier: opt.Qualifier, }); err != nil { return fmt.Errorf("failed to GetFunction %s: %w", name, err) } else { @@ -76,7 +78,7 @@ func (app *App) Diff(ctx context.Context, opt *DiffOption) error { latestJSON, _ := marshalJSON(latestFunc) newJSON, _ := marshalJSON(newFunc) - remoteArn := app.functionArn(ctx, name) + ":" + opt.Qualifier + remoteArn := fullQualifiedFunctionName(app.functionArn(ctx, name), opt.Qualifier) if opt.Unified { edits := myers.ComputeEdits(span.URIFromPath(remoteArn), string(latestJSON), string(newJSON)) @@ -116,6 +118,88 @@ func (app *App) Diff(ctx context.Context, opt *DiffOption) error { } } + if opt.FunctionURL == "" { + return nil + } + + if err := app.diffFunctionURL(ctx, name, opt); err != nil { + return err + } + return nil +} + +func (app *App) diffFunctionURL(ctx context.Context, name string, opt *DiffOption) error { + var remote, local *types.FunctionUrlConfig + fqName := fullQualifiedFunctionName(name, opt.Qualifier) + + fu, err := app.loadFunctionUrl(opt.FunctionURL, name) + if err != nil { + return fmt.Errorf("failed to load function-url: %w", err) + } else { + fillDefaultValuesFunctionUrlConfig(fu.Config) + local = &types.FunctionUrlConfig{ + AuthType: fu.Config.AuthType, + Cors: fu.Config.Cors, + InvokeMode: fu.Config.InvokeMode, + } + } + + if res, err := app.lambda.GetFunctionUrlConfig(ctx, &lambda.GetFunctionUrlConfigInput{ + FunctionName: &name, + Qualifier: opt.Qualifier, + }); err != nil { + var nfe *types.ResourceNotFoundException + if errors.As(err, &nfe) { + // empty + remote = &types.FunctionUrlConfig{} + } else { + return fmt.Errorf("failed to get function url config: %w", err) + } + } else { + log.Println("[debug] FunctionUrlConfig found") + remote = &types.FunctionUrlConfig{ + AuthType: res.AuthType, + Cors: res.Cors, + InvokeMode: res.InvokeMode, + } + } + r, _ := marshalJSON(remote) + l, _ := marshalJSON(local) + + if opt.Unified { + edits := myers.ComputeEdits(span.URIFromPath(fqName), string(r), string(l)) + if ds := fmt.Sprint(gotextdiff.ToUnified(fqName, opt.FunctionURL, string(r), edits)); ds != "" { + fmt.Print(coloredDiff(ds)) + } + } else { + if ds := diff.Diff(string(r), string(l)); ds != "" { + fmt.Println(color.RedString("---" + fqName)) + fmt.Println(color.GreenString("+++" + opt.FunctionURL)) + fmt.Print(coloredDiff(ds)) + } + } + + // permissions + adds, removes, err := app.calcFunctionURLPermissionsDiff(ctx, fu) + if err != nil { + return err + } + var addsB []byte + for _, in := range adds { + b, _ := marshalJSON(in) + addsB = append(addsB, b...) + } + var removesB []byte + for _, in := range removes { + b, _ := marshalJSON(in) + removesB = append(removesB, b...) + } + if ds := diff.Diff(string(removesB), string(addsB)); ds != "" { + fmt.Println(color.RedString("---")) + fmt.Println(color.GreenString("+++")) + fmt.Print(coloredDiff(ds)) + } + return nil } diff --git a/functionurl.go b/functionurl.go new file mode 100644 index 0000000..da9717d --- /dev/null +++ b/functionurl.go @@ -0,0 +1,425 @@ +package lambroll + +import ( + "context" + "crypto/sha1" + "encoding/json" + "errors" + "fmt" + "log" + "os" + "regexp" + "sort" + "sync" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/aws/aws-sdk-go-v2/aws/arn" + "github.com/aws/aws-sdk-go-v2/service/lambda" + "github.com/aws/aws-sdk-go-v2/service/lambda/types" + "github.com/samber/lo" +) + +var ( + SidPattern = regexp.MustCompile("^lambroll-[0-9a-f]+$") + SidFormat = "lambroll-%x" +) + +type FunctionURL struct { + Config *FunctionURLConfig `json:"Config"` + Permissions FunctionURLPermissions `json:"Permissions"` +} + +func (f *FunctionURL) Validate(functionName string) error { + if f.Config == nil { + return errors.New("function url 'Config' attribute is required") + } + f.Config.FunctionName = aws.String(functionName) + // fill default values + switch f.Config.AuthType { + case types.FunctionUrlAuthTypeNone: + if len(f.Permissions) == 0 { + f.Permissions = append(f.Permissions, &FunctionURLPermission{ + AddPermissionInput: lambda.AddPermissionInput{ + Principal: aws.String("*"), + }, + }) + } + case types.FunctionUrlAuthTypeAwsIam: + if len(f.Permissions) == 0 { + return fmt.Errorf("function url 'Permissions' attribute is required when 'AuthType' is '%s'", types.FunctionUrlAuthTypeAwsIam) + } + default: + return fmt.Errorf("unknown function url 'AuthType': %s", f.Config.AuthType) + } + return nil +} + +type FunctionURLConfig = lambda.CreateFunctionUrlConfigInput + +type FunctionURLPermissions []*FunctionURLPermission + +func (ps FunctionURLPermissions) Sids() []string { + sids := make([]string, 0, len(ps)) + for _, p := range ps { + sids = append(sids, p.Sid()) + } + sort.Strings(sids) + return sids +} + +func (ps FunctionURLPermissions) Find(sid string) *FunctionURLPermission { + for _, p := range ps { + if p.Sid() == sid { + return p + } + } + return nil +} + +type FunctionURLPermission struct { + lambda.AddPermissionInput + + sid string + once sync.Once +} + +func (p *FunctionURLPermission) Sid() string { + p.once.Do(func() { + b, _ := json.Marshal(p) + h := sha1.Sum(b) + p.sid = fmt.Sprintf(SidFormat, h) + }) + return p.sid +} + +type PolicyOutput struct { + Id string `json:"Id"` + Version string `json:"Version"` + Statement []PolicyStatement `json:"Statement"` +} + +type PolicyStatement struct { + Sid string `json:"Sid"` + Effect string `json:"Effect"` + Principal any `json:"Principal"` + Action string `json:"Action"` + Resource any `json:"Resource"` + Condition any `json:"Condition"` +} + +func (ps *PolicyStatement) PrincipalAccountID() *string { + if ps.Principal == nil { + return nil + } + switch v := ps.Principal.(type) { + case string: + return aws.String(v) + case map[string]interface{}: + if v["AWS"] == nil { + return nil + } + switch vv := v["AWS"].(type) { + case string: + if a, err := arn.Parse(vv); err == nil { + return aws.String(a.AccountID) + } + return aws.String(vv) + } + } + return nil +} + +func (ps *PolicyStatement) PrincipalOrgID() *string { + principal := ps.PrincipalAccountID() + if principal == nil || *principal != "*" { + return nil + } + m, ok := ps.Condition.(map[string]interface{}) + if !ok { + return nil + } + if m["StringEquals"] == nil { + return nil + } + mm, ok := m["StringEquals"].(map[string]interface{}) + if !ok { + return nil + } + if mm["lambda:FunctionUrlAuthType"] == nil { + return nil + } + if v, ok := mm["lambda:FunctionUrlAuthType"].(string); ok && v != "AWS_IAM" { + return nil + } + if mm["aws:PrincipalOrgID"] == nil { + return nil + } + if v, ok := mm["aws:PrincipalOrgID"].(string); ok { + return aws.String(v) + } + return nil +} + +func (app *App) loadFunctionUrl(path string, functionName string) (*FunctionURL, error) { + f, err := loadDefinitionFile[FunctionURL](app, path, DefaultFunctionURLFilenames) + if err != nil { + return nil, err + } + if err := f.Validate(functionName); err != nil { + return nil, err + } + return f, nil +} + +func (app *App) deployFunctionURL(ctx context.Context, fc *FunctionURL) error { + log.Println("[info] deploying function url...") + + if err := app.deployFunctionURLConfig(ctx, fc); err != nil { + return fmt.Errorf("failed to deploy function url config: %w", err) + } + + if err := app.deployFunctionURLPermissions(ctx, fc); err != nil { + return fmt.Errorf("failed to deploy function url permissions: %w", err) + } + + log.Println("[info] deployed function url") + return nil +} + +func (app *App) deployFunctionURLConfig(ctx context.Context, fc *FunctionURL) error { + create := false + fqFunctionName := fullQualifiedFunctionName(*fc.Config.FunctionName, fc.Config.Qualifier) + functinoUrlConfig, err := app.lambda.GetFunctionUrlConfig(ctx, &lambda.GetFunctionUrlConfigInput{ + FunctionName: fc.Config.FunctionName, + Qualifier: fc.Config.Qualifier, + }) + if err != nil { + var nfe *types.ResourceNotFoundException + if errors.As(err, &nfe) { + log.Printf("[info] function url config for %s not found. creating", fqFunctionName) + create = true + } else { + return fmt.Errorf("failed to get function url config: %w", err) + } + } + + if create { + res, err := app.lambda.CreateFunctionUrlConfig(ctx, fc.Config) + if err != nil { + return fmt.Errorf("failed to create function url config: %w", err) + } + log.Printf("[info] created function url config for %s", fqFunctionName) + log.Printf("[info] Function URL: %s", *res.FunctionUrl) + } else { + log.Printf("[info] updating function url config for %s", fqFunctionName) + if functinoUrlConfig.Cors != nil && fc.Config.Cors == nil { + // reset cors config + fc.Config.Cors = &types.Cors{} + } + res, err := app.lambda.UpdateFunctionUrlConfig(ctx, &lambda.UpdateFunctionUrlConfigInput{ + FunctionName: fc.Config.FunctionName, + Qualifier: fc.Config.Qualifier, + AuthType: fc.Config.AuthType, + Cors: fc.Config.Cors, + InvokeMode: fc.Config.InvokeMode, + }) + if err != nil { + return fmt.Errorf("failed to update function url config: %w", err) + } + log.Printf("[info] updated function url config for %s", fqFunctionName) + log.Printf("[info] Function URL: %s", *res.FunctionUrl) + } + return nil +} + +func (app *App) deployFunctionURLPermissions(ctx context.Context, fc *FunctionURL) error { + adds, removes, err := app.calcFunctionURLPermissionsDiff(ctx, fc) + if err != nil { + return err + } + if len(adds) == 0 && len(removes) == 0 { + log.Println("[info] no changes in permissions.") + return nil + } + + log.Printf("[info] adding %d permissions", len(adds)) + for _, in := range adds { + if _, err := app.lambda.AddPermission(ctx, in); err != nil { + return fmt.Errorf("failed to add permission: %w", err) + } + log.Printf("[info] added permission Sid: %s", *in.StatementId) + } + + log.Printf("[info] removing %d permissions", len(removes)) + for _, in := range removes { + if _, err := app.lambda.RemovePermission(ctx, in); err != nil { + return fmt.Errorf("failed to remove permission: %w", err) + } + log.Printf("[info] removed permission Sid: %s", *in.StatementId) + } + + return nil +} + +func (app *App) calcFunctionURLPermissionsDiff(ctx context.Context, fc *FunctionURL) ([]*lambda.AddPermissionInput, []*lambda.RemovePermissionInput, error) { + fqFunctionName := fullQualifiedFunctionName(*fc.Config.FunctionName, fc.Config.Qualifier) + existsSids := []string{} + { + res, err := app.lambda.GetPolicy(ctx, &lambda.GetPolicyInput{ + FunctionName: fc.Config.FunctionName, + Qualifier: fc.Config.Qualifier, + }) + if err != nil { + var nfe *types.ResourceNotFoundException + if errors.As(err, &nfe) { + // do nothing + } else { + return nil, nil, fmt.Errorf("failed to get policy: %w", err) + } + } + if res != nil { + log.Printf("[debug] policy for %s: %s", fqFunctionName, *res.Policy) + var policy PolicyOutput + if err := json.Unmarshal([]byte(*res.Policy), &policy); err != nil { + return nil, nil, fmt.Errorf("failed to unmarshal policy: %w", err) + } + for _, s := range policy.Statement { + if s.Action != "lambda:InvokeFunctionUrl" || s.Effect != "Allow" { + // not a lambda function url policy + continue + } + existsSids = append(existsSids, s.Sid) + } + sort.Strings(existsSids) + } + } + + removeSids, addSids := lo.Difference(existsSids, fc.Permissions.Sids()) + if len(removeSids) == 0 && len(addSids) == 0 { + return nil, nil, nil + } + + var adds []*lambda.AddPermissionInput + for _, sid := range addSids { + p := fc.Permissions.Find(sid) + if p == nil { + // should not happen + panic(fmt.Sprintf("permission not found: %s", sid)) + } + in := &lambda.AddPermissionInput{ + Action: aws.String("lambda:InvokeFunctionUrl"), + FunctionName: fc.Config.FunctionName, + Qualifier: fc.Config.Qualifier, + FunctionUrlAuthType: fc.Config.AuthType, + StatementId: aws.String(sid), + Principal: p.Principal, + PrincipalOrgID: p.PrincipalOrgID, + } + adds = append(adds, in) + } + + var removes []*lambda.RemovePermissionInput + for _, sid := range removeSids { + in := &lambda.RemovePermissionInput{ + FunctionName: fc.Config.FunctionName, + Qualifier: fc.Config.Qualifier, + StatementId: aws.String(sid), + } + removes = append(removes, in) + } + + return adds, removes, nil +} + +func (app *App) initFunctionURL(ctx context.Context, fn *Function, opt *InitOption) error { + fc, err := app.lambda.GetFunctionUrlConfig(ctx, &lambda.GetFunctionUrlConfigInput{ + FunctionName: fn.FunctionName, + Qualifier: opt.Qualifier, + }) + if err != nil { + var nfe *types.ResourceNotFoundException + if errors.As(err, &nfe) { + log.Printf("[warn] function url config for %s not found", *fn.FunctionName) + return nil + } else { + return fmt.Errorf("failed to get function url config: %w", err) + } + } + fqFunctionName := fullQualifiedFunctionName(*fn.FunctionName, opt.Qualifier) + fu := &FunctionURL{ + Config: &lambda.CreateFunctionUrlConfigInput{ + Cors: fc.Cors, + AuthType: fc.AuthType, + InvokeMode: fc.InvokeMode, + Qualifier: opt.Qualifier, + }, + } + + { + res, err := app.lambda.GetPolicy(ctx, &lambda.GetPolicyInput{ + FunctionName: fn.FunctionName, + Qualifier: opt.Qualifier, + }) + if err != nil { + var nfe *types.ResourceNotFoundException + if errors.As(err, &nfe) { + // do nothing + } else { + return fmt.Errorf("failed to get policy: %w", err) + } + } + if res != nil { + log.Printf("[debug] policy for %s: %s", fqFunctionName, *res.Policy) + var policy PolicyOutput + if err := json.Unmarshal([]byte(*res.Policy), &policy); err != nil { + return fmt.Errorf("failed to unmarshal policy: %w", err) + } + for _, s := range policy.Statement { + if s.Action != "lambda:InvokeFunctionUrl" || s.Effect != "Allow" { + // not a lambda function url policy + continue + } + b, _ := marshalJSON(s) + log.Printf("[debug] statement: %s", string(b)) + pm := &FunctionURLPermission{ + AddPermissionInput: lambda.AddPermissionInput{ + Principal: s.PrincipalAccountID(), + PrincipalOrgID: s.PrincipalOrgID(), + }, + } + b, _ = marshalJSON(pm) + log.Printf("[debug] permission: %s", string(b)) + fu.Permissions = append(fu.Permissions, pm) + } + } + } + + var name string + if opt.Jsonnet { + name = DefaultFunctionURLFilenames[1] + } else { + name = DefaultFunctionURLFilenames[0] + } + log.Printf("[info] creating %s", name) + b, _ := marshalJSON(fu) + if opt.Jsonnet { + b, err = jsonToJsonnet(b, name) + if err != nil { + return err + } + } + if err := app.saveFile(name, b, os.FileMode(0644)); err != nil { + return err + } + + return nil +} + +func fillDefaultValuesFunctionUrlConfig(fc *FunctionURLConfig) { + if fc.AuthType == "" { + fc.AuthType = types.FunctionUrlAuthTypeNone + } + if fc.InvokeMode == "" { + fc.InvokeMode = types.InvokeModeBuffered + } +} diff --git a/functionurl_test.go b/functionurl_test.go new file mode 100644 index 0000000..f44f514 --- /dev/null +++ b/functionurl_test.go @@ -0,0 +1,87 @@ +package lambroll_test + +import ( + "encoding/json" + "testing" + + "github.com/aws/aws-sdk-go-v2/aws" + "github.com/fujiwara/lambroll" + "github.com/go-test/deep" +) + +var permissonsTestCases = []struct { + subject string + statementJSON []byte + expectedPrincipal *string + expectedPrincipalOrgID *string +}{ + { + subject: "AuthType NONE", + statementJSON: []byte(`{ + "Action": "lambda:InvokeFunctionUrl", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "NONE" + } + }, + "Effect": "Allow", + "Principal": "*", + "Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:hello", + "Sid": "lambroll-8f4ec83e623a309d9ca15db9276da30b2129be9c" + }`), + expectedPrincipal: aws.String("*"), + expectedPrincipalOrgID: nil, + }, + { + subject: "AuthType AWS_IAM with Principal OrgID", + statementJSON: []byte(`{ + "Sid": "lambroll-622ed5c2bb0714ef0af1929fcea568e4ba0c4dbe", + "Effect": "Allow", + "Principal": "*", + "Action": "lambda:InvokeFunctionUrl", + "Resource": "arn:aws:lambda:ap-northeast-1:1234567890:function:hello", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "AWS_IAM", + "aws:PrincipalOrgID": "o-xxxxxxxxxx" + } + } + }`), + expectedPrincipal: aws.String("*"), + expectedPrincipalOrgID: aws.String("o-xxxxxxxxxx"), + }, + { + subject: "AuthType AWS_IAM with Principal", + statementJSON: []byte(`{ + "Action": "lambda:InvokeFunctionUrl", + "Condition": { + "StringEquals": { + "lambda:FunctionUrlAuthType": "AWS_IAM" + } + }, + "Effect": "Allow", + "Principal": { + "AWS": "arn:aws:iam::123456789012:root" + }, + "Resource": "arn:aws:lambda:ap-northeast-1:123456789012:function:hello", + "Sid": "lambroll-3b135eca4b14335775cda9f947966093a57d270f" + }`), + expectedPrincipal: aws.String("123456789012"), + expectedPrincipalOrgID: nil, + }, +} + +func TestParseStatement(t *testing.T) { + for _, c := range permissonsTestCases { + st := &lambroll.PolicyStatement{} + if err := json.Unmarshal(c.statementJSON, st); err != nil { + t.Errorf("%s failed to unmarshal json: %s", c.subject, err) + } + if diff := deep.Equal(c.expectedPrincipal, st.PrincipalAccountID()); diff != nil { + t.Errorf("%s PrincipalAccountID diff %s", c.subject, diff) + } + if diff := deep.Equal(c.expectedPrincipalOrgID, st.PrincipalOrgID()); diff != nil { + t.Errorf("%s PrincipalOrgID diff %s", c.subject, diff) + } + } +} diff --git a/go.mod b/go.mod index 6ccf0d5..57dabab 100644 --- a/go.mod +++ b/go.mod @@ -23,6 +23,7 @@ require ( github.com/kylelemons/godebug v1.1.0 github.com/mattn/go-isatty v0.0.20 github.com/olekukonko/tablewriter v0.0.5 + github.com/samber/lo v1.38.1 github.com/shogo82148/go-retry v1.1.1 golang.org/x/sys v0.14.0 ) @@ -84,6 +85,7 @@ require ( github.com/rivo/uniseg v0.2.0 // indirect go.opencensus.io v0.23.0 // indirect golang.org/x/crypto v0.12.0 // indirect + golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 // indirect golang.org/x/lint v0.0.0-20201208152925-83fdc39ff7b5 // indirect golang.org/x/mod v0.8.0 // indirect golang.org/x/net v0.14.0 // indirect diff --git a/go.sum b/go.sum index 67b9314..a29522c 100644 --- a/go.sum +++ b/go.sum @@ -297,6 +297,8 @@ github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1: github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4= +github.com/samber/lo v1.38.1 h1:j2XEAqXKb09Am4ebOg31SpvzUTTs6EN3VfgeLUhPdXM= +github.com/samber/lo v1.38.1/go.mod h1:+m/ZKRl6ClXCE2Lgf3MsQlWfh4bn1bz6CXEOxnEXnEA= github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= github.com/shogo82148/go-retry v1.1.1 h1:BfUEVHTNDSjYxoRPC+c/ht5Sy6qdwl+0kFhhubeh4Fo= github.com/shogo82148/go-retry v1.1.1/go.mod h1:TPSFDcc2rlx2D/yfhi8BBOlsHhVBjjJoMvxG7iFHUbI= @@ -337,6 +339,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17 h1:3MTrJm4PyNL9NBqvYDSj3DHl46qQakyfqfWo4jgfaEM= +golang.org/x/exp v0.0.0-20220303212507-bbda1eaf7a17/go.mod h1:lgLbSvA5ygNOMpwM/9anMpWVlVJ7Z+cHWq/eFuinpGE= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/init.go b/init.go index 74c64d3..e412f58 100644 --- a/init.go +++ b/init.go @@ -20,12 +20,15 @@ type InitOption struct { FunctionName *string `help:"Function name for init" required:"true" default:""` DownloadZip bool `help:"Download function.zip" default:"false"` Jsonnet bool `default:"false" help:"render function.json as jsonnet"` + Qualifier *string `help:"function version or alias"` + FunctionURL bool `help:"create function url definition file" default:"false"` } // Init initializes function.json func (app *App) Init(ctx context.Context, opt *InitOption) error { res, err := app.lambda.GetFunction(ctx, &lambda.GetFunctionInput{ FunctionName: opt.FunctionName, + Qualifier: opt.Qualifier, }) var c *types.FunctionConfiguration exists := true @@ -61,7 +64,7 @@ func (app *App) Init(ctx context.Context, opt *InitOption) error { arn := app.functionArn(ctx, *c.FunctionName) log.Printf("[debug] listing tags of %s", arn) res, err := app.lambda.ListTags(ctx, &lambda.ListTagsInput{ - Resource: aws.String(arn), + Resource: aws.String(arn), // tags are not supported for alias }) if err != nil { return fmt.Errorf("failed to list tags: %w", err) @@ -102,7 +105,17 @@ func (app *App) Init(ctx context.Context, opt *InitOption) error { return err } } - return app.saveFile(name, b, os.FileMode(0644)) + if err := app.saveFile(name, b, os.FileMode(0644)); err != nil { + return err + } + + if opt.FunctionURL { + if err := app.initFunctionURL(ctx, fn, opt); err != nil { + return err + } + } + + return nil } func download(url, path string) error { diff --git a/lambroll.go b/lambroll.go index 0b6d4a7..0821ef4 100644 --- a/lambroll.go +++ b/lambroll.go @@ -61,6 +61,11 @@ var ( "function.jsonnet", } + DefaultFunctionURLFilenames = []string{ + "function_url.json", + "function_url.jsonnet", + } + // FunctionZipFilename defines file name for zip archive downloaded at init. FunctionZipFilename = "function.zip" @@ -69,6 +74,8 @@ var ( IgnoreFilename, DefaultFunctionFilenames[0], DefaultFunctionFilenames[1], + DefaultFunctionURLFilenames[0], + DefaultFunctionURLFilenames[1], FunctionZipFilename, ".git/*", ".terraform/*", @@ -202,9 +209,9 @@ func (app *App) AWSAccountID(ctx context.Context) string { return app.accountID } -func (app *App) loadFunction(path string) (*Function, error) { +func loadDefinitionFile[T any](app *App, path string, defaults []string) (*T, error) { if path == "" { - p, err := FindFunctionFile("") + p, err := findDefinitionFile("", defaults) if err != nil { return nil, err } @@ -238,11 +245,15 @@ func (app *App) loadFunction(path string) (*Function, error) { return nil, err } } - var fn Function - if err := unmarshalJSON(src, &fn, path); err != nil { + var v T + if err := unmarshalJSON(src, &v, path); err != nil { return nil, fmt.Errorf("failed to load %s: %w", path, err) } - return &fn, nil + return &v, nil +} + +func (app *App) loadFunction(path string) (*Function, error) { + return loadDefinitionFile[Function](app, path, DefaultFunctionFilenames) } func newFunctionFrom(c *types.FunctionConfiguration, code *types.FunctionCodeLocation, tags Tags) *Function { diff --git a/render.go b/render.go index 626e841..1c4c102 100644 --- a/render.go +++ b/render.go @@ -7,7 +7,8 @@ import ( ) type RenderOption struct { - Jsonnet bool `default:"false" help:"render function.json as jsonnet"` + Jsonnet bool `default:"false" help:"render function.json as jsonnet"` + FunctionURL string `help:"render function-url definiton file" default:""` } // Invoke invokes function @@ -16,10 +17,23 @@ func (app *App) Render(ctx context.Context, opt *RenderOption) error { if err != nil { return fmt.Errorf("failed to load function: %w", err) } - b, err := marshalJSON(fn) - if err != nil { - return fmt.Errorf("failed to marshal function: %w", err) + var b []byte + if opt.FunctionURL != "" { + fu, err := app.loadFunctionUrl(opt.FunctionURL, *fn.FunctionName) + if err != nil { + return fmt.Errorf("failed to load function-url: %w", err) + } + b, err = marshalJSON(fu) + if err != nil { + return fmt.Errorf("failed to marshal function-url: %w", err) + } + } else { + b, err = marshalJSON(fn) + if err != nil { + return fmt.Errorf("failed to marshal function: %w", err) + } } + if opt.Jsonnet { b, err = jsonToJsonnet(b, app.functionFilePath) if err != nil { diff --git a/status.go b/status.go index 3816975..02f8df4 100644 --- a/status.go +++ b/status.go @@ -2,6 +2,7 @@ package lambroll import ( "context" + "errors" "fmt" "log" "strings" @@ -9,18 +10,20 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" "github.com/aws/aws-sdk-go-v2/service/lambda" "github.com/aws/aws-sdk-go-v2/service/lambda/types" + "github.com/samber/lo" ) // StatusOption represents options for Status() type StatusOption struct { - Qualifier string `help:"compare with" default:"$LATEST"` - Output string `default:"text" enum:"text,json" help:"output format"` + Qualifier *string `help:"compare with"` + Output string `default:"text" enum:"text,json" help:"output format"` } type FunctionStatusOutput struct { Configuration *types.FunctionConfiguration Code *types.FunctionCodeLocation Tags Tags + FunctionURL *types.FunctionUrlConfig } // Status prints status of function @@ -37,7 +40,7 @@ func (app *App) Status(ctx context.Context, opt *StatusOption) error { if res, err := app.lambda.GetFunction(ctx, &lambda.GetFunctionInput{ FunctionName: &name, - Qualifier: &opt.Qualifier, + Qualifier: opt.Qualifier, }); err != nil { return fmt.Errorf("failed to GetFunction %s: %w", name, err) } else { @@ -56,11 +59,34 @@ func (app *App) Status(ctx context.Context, opt *StatusOption) error { tags = res.Tags } } + st := &FunctionStatusOutput{ Configuration: configuration, Code: code, Tags: tags, } + + if res, err := app.lambda.GetFunctionUrlConfig(ctx, &lambda.GetFunctionUrlConfigInput{ + FunctionName: fn.FunctionName, + Qualifier: opt.Qualifier, + }); err != nil { + var nfe *types.ResourceNotFoundException + if errors.As(err, &nfe) { + // ignore + log.Println("[debug] FunctionUrlConfig not found") + } else { + return fmt.Errorf("failed to get function url config: %w", err) + } + } else { + log.Println("[debug] FunctionUrlConfig found") + st.FunctionURL = &types.FunctionUrlConfig{ + FunctionUrl: res.FunctionUrl, + AuthType: res.AuthType, + Cors: res.Cors, + InvokeMode: res.InvokeMode, + } + } + switch opt.Output { case "text": fmt.Print(st.String()) @@ -101,7 +127,7 @@ func (st *FunctionStatusOutput) String() string { }, "\n") } - res := strings.Join([]string{ + res := []string{ "FunctionName: " + aws.ToString(st.Configuration.FunctionName), "Description: " + aws.ToString(st.Configuration.Description), "Version: " + aws.ToString(st.Configuration.Version), @@ -120,6 +146,36 @@ func (st *FunctionStatusOutput) String() string { "CodeSize: " + fmt.Sprintf("%d", st.Configuration.CodeSize), "CodeSha256: " + aws.ToString(st.Configuration.CodeSha256), "Tags: " + strings.Join(tags, ","), - }, "\n") + "\n" - return res + } + + if st.FunctionURL != nil { + res = append(res, []string{ + "FunctionUrl:", + " FunctionUrl: " + aws.ToString(st.FunctionURL.FunctionUrl), + " AuthType: " + string(st.FunctionURL.AuthType), + " InvokeMode: " + string(st.FunctionURL.InvokeMode), + }...) + if cors := st.FunctionURL.Cors; cors != nil { + res = append(res, " Cors:", formatCors(cors, 4)) + } + } + return strings.Join(res, "\n") + "\n" +} + +func formatCors(cors *types.Cors, indentLevel int) string { + if cors == nil { + return "" + } + indent := strings.Repeat(" ", indentLevel) + res := lo.Map([]string{ + "AllowCredentials: " + fmt.Sprintf("%t", aws.ToBool(cors.AllowCredentials)), + "AllowOrigins: " + strings.Join(cors.AllowOrigins, ","), + "AllowHeaders: " + strings.Join(cors.AllowHeaders, ","), + "AllowMethods: " + strings.Join(cors.AllowMethods, ","), + "ExposeHeaders: " + strings.Join(cors.ExposeHeaders, ","), + "MaxAge: " + fmt.Sprintf("%d", aws.ToInt32(cors.MaxAge)), + }, func(item string, _ int) string { + return indent + item + }) + return strings.Join(res, "\n") } diff --git a/utils.go b/utils.go index d1d466b..8ce0adb 100644 --- a/utils.go +++ b/utils.go @@ -54,7 +54,7 @@ func unmarshalJSON(src []byte, v interface{}, path string) error { return nil } -func FindFunctionFile(preffered string) (string, error) { +func findDefinitionFile(preffered string, defaults []string) (string, error) { if preffered != "" { if _, err := os.Stat(preffered); err == nil { return preffered, nil @@ -62,7 +62,7 @@ func FindFunctionFile(preffered string) (string, error) { return "", err } } - for _, name := range DefaultFunctionFilenames { + for _, name := range defaults { if _, err := os.Stat(name); err == nil { return name, nil } @@ -84,3 +84,10 @@ func resolveLogGroup(fn *Function) string { } return fmt.Sprintf("/aws/lambda/%s", *fn.FunctionName) } + +func fullQualifiedFunctionName(name string, qualifier *string) string { + if qualifier != nil { + return name + ":" + *qualifier + } + return name + ":" + versionLatest +}