Compare commits

...

1 Commits

Author SHA1 Message Date
joshsiv-mulga 375200a26b feat(lambda): add ListVersionsByFunction listing only $LATEST
Until versions can be published, the only version is $LATEST: the list holds
its configuration with the FunctionArn qualified as :$LATEST and no
NextMarker. MaxItems outside 1 to 10000 is refused, and Marker is refused
outright rather than guessing AWS's paging behaviour. It is authorized as
lambda:ListVersionsByFunction on the function ARN and does not need the code
store.
2026-11-03 11:08:01 +10:00
5 changed files with 149 additions and 7 deletions
+28 -4
View File
@@ -5,6 +5,7 @@ import (
"errors"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/aws/aws-sdk-go/aws"
@@ -18,6 +19,9 @@ import (
// codeURLTTL is how long the presigned code location stays valid.
const codeURLTTL = 10 * time.Minute
// maxListItems is the largest MaxItems a list action accepts.
const maxListItems = 10000
func notFound(region, accountID, name string) error {
return awserrors.Errorf(awserrors.ErrorResourceNotFoundException, "Function not found: %s",
arn.FormatLambdaFunction(region, accountID, name))
@@ -48,8 +52,8 @@ func (d Deps) lookup(ctx context.Context, c *call, query string) (function.Recor
// loadConfiguration resolves the requested $LATEST function and renders its
// configuration; it is a metadata read and never touches the code store.
func (d Deps) loadConfiguration(ctx context.Context, c *call) (function.Record, *lambda.FunctionConfiguration, error) {
rec, revision, err := d.lookup(ctx, c, c.Query.Get("Qualifier"))
func (d Deps) loadConfiguration(ctx context.Context, c *call, query string) (function.Record, *lambda.FunctionConfiguration, error) {
rec, revision, err := d.lookup(ctx, c, query)
if err != nil {
return function.Record{}, nil, err
}
@@ -73,7 +77,7 @@ func (d Deps) loadConfiguration(ctx context.Context, c *call) (function.Record,
// getFunctionConfiguration implements GetFunctionConfiguration for $LATEST.
func (d Deps) getFunctionConfiguration(ctx context.Context, c *call) (reply, error) {
_, cfg, err := d.loadConfiguration(ctx, c)
_, cfg, err := d.loadConfiguration(ctx, c, c.Query.Get("Qualifier"))
if err != nil {
return reply{}, err
}
@@ -93,9 +97,29 @@ func (d Deps) getFunctionCodeSigningConfig(ctx context.Context, c *call) (reply,
return reply{Status: http.StatusOK, Body: noCodeSigning}, nil
}
// listVersionsByFunction implements ListVersionsByFunction while $LATEST is
// the only version. Paging is refused rather than guessed at.
func (d Deps) listVersionsByFunction(ctx context.Context, c *call) (reply, error) {
if raw, ok := c.Query["MaxItems"]; ok {
n, err := strconv.Atoi(raw[0])
if err != nil || n < 1 || n > maxListItems {
return reply{}, invalid("MaxItems must be between 1 and %d", maxListItems)
}
}
if c.Query.Has("Marker") {
return reply{}, unsupported("Marker")
}
rec, cfg, err := d.loadConfiguration(ctx, c, "")
if err != nil {
return reply{}, err
}
cfg.FunctionArn = aws.String(arn.FormatLambdaFunction(rec.Region, rec.AccountID, rec.FunctionName) + ":$LATEST")
return reply{Status: http.StatusOK, Body: &lambda.ListVersionsByFunctionOutput{Versions: []*lambda.FunctionConfiguration{cfg}}}, nil
}
// getFunction implements GetFunction for the $LATEST version.
func (d Deps) getFunction(ctx context.Context, c *call) (reply, error) {
rec, cfg, err := d.loadConfiguration(ctx, c)
rec, cfg, err := d.loadConfiguration(ctx, c, c.Query.Get("Qualifier"))
if err != nil {
return reply{}, err
}
+107 -1
View File
@@ -11,6 +11,7 @@ import (
"errors"
"net/http"
"net/http/httptest"
"net/url"
"runtime"
"strings"
"sync"
@@ -662,7 +663,8 @@ func TestAuthorization(t *testing.T) {
func TestRegistration_ResolvesActionsAndInventory(t *testing.T) {
reg := awsapi.NewRegistration(awsapi.Deps{})
assert.Equal(t, []string{"CreateFunction", "GetFunction", "GetFunctionCodeSigningConfig", "GetFunctionConfiguration"}, reg.Inventory.Registered)
assert.Equal(t, []string{"CreateFunction", "GetFunction", "GetFunctionCodeSigningConfig", "GetFunctionConfiguration", "ListVersionsByFunction"}, reg.Inventory.Registered)
assert.Equal(t, "ListVersionsByFunction", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2015-03-31/functions/x/versions", nil)))
assert.Equal(t, "GetFunctionCodeSigningConfig", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2020-06-30/functions/x/code-signing-config", nil)))
assert.Equal(t, "CreateFunction", reg.ResolveAction(httptest.NewRequest(http.MethodPost, "/2015-03-31/functions", nil)))
assert.Equal(t, "GetFunction", reg.ResolveAction(httptest.NewRequest(http.MethodGet, "/2015-03-31/functions/x", nil)))
@@ -870,3 +872,107 @@ func TestGetFunctionCodeSigningConfig_Authorization(t *testing.T) {
require.NoError(t, get(setup(t, gwtest.Statement("Allow", "lambda:GetFunctionCodeSigningConfig", arn))))
})
}
func TestListVersionsByFunction_ListsOnlyLatest(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", func(b map[string]any) {
b["Environment"] = map[string]any{"Variables": map[string]string{"MODE": "prod"}}
}))
require.NoError(t, err)
rec, err := e.do(http.MethodGet, "/2015-03-31/functions/hello/configuration", nil)
require.NoError(t, err)
var cfg lambda.FunctionConfiguration
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &cfg))
arn := "arn:aws:lambda:" + gwtest.Region + ":" + gwtest.AccountID + ":function:hello"
for _, path := range []string{
"/2015-03-31/functions/hello/versions",
"/2015-03-31/functions/hello/versions?MaxItems=10000",
"/2015-03-31/functions/hello/versions?MaxItems=1",
"/2015-03-31/functions/" + url.PathEscape(arn) + "/versions",
} {
rec, err := e.do(http.MethodGet, path, nil)
require.NoError(t, err, path)
require.Equal(t, http.StatusOK, rec.Code, path)
assert.NotContains(t, rec.Body.String(), "NextMarker", path)
var out lambda.ListVersionsByFunctionOutput
require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &out), path)
require.Len(t, out.Versions, 1, path)
v := out.Versions[0]
assert.Equal(t, arn+":$LATEST", *v.FunctionArn, path)
assert.Equal(t, "$LATEST", *v.Version, path)
assert.Equal(t, "prod", *v.Environment.Variables["MODE"], path)
v.FunctionArn = cfg.FunctionArn
assert.Equal(t, cfg, *v, "apart from the qualified ARN it is the configuration read")
}
}
func TestListVersionsByFunction_RefusesBadMaxItemsAndAnyMarker(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
for _, q := range []string{"MaxItems=0", "MaxItems=10001", "MaxItems=-1", "MaxItems=ten", "MaxItems=", "Marker=not-a-marker", "Marker="} {
_, err := e.do(http.MethodGet, "/2015-03-31/functions/hello/versions?"+q, nil)
requireCode(t, err, awserrors.ErrorInvalidParameterValueException)
}
}
func TestListVersionsByFunction_MissingFunctionIsNotFound(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
_, err = e.do(http.MethodGet, "/2015-03-31/functions/ghost/versions", nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
_, getErr := e.do(http.MethodGet, "/2015-03-31/functions/ghost", nil)
assert.Equal(t, getErr.Error(), err.Error())
e.h.Region = "us-west-2"
_, err = e.do(http.MethodGet, "/2015-03-31/functions/hello/versions", nil)
requireCode(t, err, awserrors.ErrorResourceNotFoundException)
}
func TestListVersionsByFunction_DoesNotNeedTheCodeStore(t *testing.T) {
e := newEnv(t)
_, err := e.do(http.MethodPost, "/2015-03-31/functions", createBody("hello", nil))
require.NoError(t, err)
e.deps.Codes = func(context.Context) (awsapi.CodeStore, error) { return nil, errors.New("code store down") }
_, err = e.do(http.MethodGet, "/2015-03-31/functions/hello/versions", nil)
require.NoError(t, err)
e.deps.Codes = nil
_, err = e.do(http.MethodGet, "/2015-03-31/functions/hello/versions", nil)
require.NoError(t, err)
}
func TestListVersionsByFunction_Authorization(t *testing.T) {
arn := "arn:aws:lambda:" + gwtest.Region + ":" + gwtest.AccountID + ":function:hello"
setup := func(t *testing.T, statements ...iamdomain.Statement) *env {
e := newEnv(t, statements...)
_, err := e.meta.Create(t.Context(), gwtest.AccountID, gwtest.Region, function.Record{
FunctionID: "f1", FunctionName: "hello", Role: roleARN, Runtime: "provided.al2023", Handler: "h",
Timeout: 3, MemorySize: 128, Code: function.Code{RevisionID: "r1"},
})
require.NoError(t, err)
return e
}
list := func(e *env) error {
_, err := e.do(http.MethodGet, "/2015-03-31/functions/hello/versions", nil)
return err
}
t.Run("lambda:GetFunction alone is not enough", func(t *testing.T) {
gwtest.AssertDenied(t, list(setup(t, gwtest.Statement("Allow", "lambda:GetFunction", "*"))))
})
t.Run("a deny on the function ARN applies", func(t *testing.T) {
gwtest.AssertDenied(t, list(setup(t,
gwtest.Statement("Allow", "lambda:*", "*"),
gwtest.Statement("Deny", "lambda:ListVersionsByFunction", arn))))
})
t.Run("an allow on another function ARN does not apply", func(t *testing.T) {
gwtest.AssertDenied(t, list(setup(t, gwtest.Statement("Allow", "lambda:ListVersionsByFunction", arn+"-other"))))
})
t.Run("an allow on the function ARN passes", func(t *testing.T) {
require.NoError(t, list(setup(t, gwtest.Statement("Allow", "lambda:ListVersionsByFunction", arn))))
})
}
@@ -117,7 +117,7 @@ func (d Deps) serve(w http.ResponseWriter, inv dispatch.Invocation) error {
if decodeErr == nil {
resource = functionARN(inv.Region, inv.AccountID, aws.StringValue(c.Create.FunctionName))
}
case "GetFunction", "GetFunctionConfiguration", "GetFunctionCodeSigningConfig":
case "GetFunction", "GetFunctionConfiguration", "GetFunctionCodeSigningConfig", "ListVersionsByFunction":
if name, _, err := resolveFunctionName(inv.Region, inv.AccountID, params[0]); err == nil {
resource = functionARN(inv.Region, inv.AccountID, name)
}
@@ -134,7 +134,8 @@ func (d Deps) serve(w http.ResponseWriter, inv dispatch.Invocation) error {
}
}
// Only GetFunction presigns code; the other reads are metadata only.
needsCodes := action != "GetFunctionConfiguration" && action != "GetFunctionCodeSigningConfig"
needsCodes := action != "GetFunctionConfiguration" && action != "GetFunctionCodeSigningConfig" &&
action != "ListVersionsByFunction"
if d.Meta == nil || (d.Codes == nil && needsCodes) {
return errors.New(awserrors.ErrorServiceException)
}
+4
View File
@@ -49,6 +49,10 @@ var routes = []route{
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
return d.getFunctionConfiguration(ctx, c)
}},
{Method: "GET", Pattern: "/2015-03-31/functions/{FunctionName}/versions", Action: "ListVersionsByFunction",
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
return d.listVersionsByFunction(ctx, c)
}},
{Method: "GET", Pattern: "/2020-06-30/functions/{FunctionName}/code-signing-config", Action: "GetFunctionCodeSigningConfig",
Handler: func(ctx context.Context, d Deps, c *call) (reply, error) {
return d.getFunctionCodeSigningConfig(ctx, c)
+7
View File
@@ -138,6 +138,13 @@ func TestLambda_SignedCreateAndGetDownloadsTheStoredCode(t *testing.T) {
require.NoError(t, err)
require.Nil(t, signing.CodeSigningConfigArn)
require.Nil(t, signing.FunctionName)
versions, err := cli.ListVersionsByFunction(&lambda.ListVersionsByFunctionInput{FunctionName: aws.String("e2e-fn"), MaxItems: aws.Int64(10000)})
require.NoError(t, err)
require.Len(t, versions.Versions, 1)
require.Equal(t, aws.StringValue(got.Configuration.FunctionArn)+":$LATEST", aws.StringValue(versions.Versions[0].FunctionArn))
require.Equal(t, "$LATEST", aws.StringValue(versions.Versions[0].Version))
require.Nil(t, versions.NextMarker)
_, err = cli.GetFunctionCodeSigningConfig(&lambda.GetFunctionCodeSigningConfigInput{FunctionName: aws.String("e2e-missing")})
var aerr awserr.Error
require.ErrorAs(t, err, &aerr)