374 lines
12 KiB
TypeScript
374 lines
12 KiB
TypeScript
/**
|
|
* Lambda Layers compatibility tests.
|
|
*
|
|
* Verifies that the real @aws-sdk/client-lambda can round-trip through
|
|
* Floci's layer endpoints without wire-format issues.
|
|
*/
|
|
|
|
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
|
|
import {
|
|
LambdaClient,
|
|
PublishLayerVersionCommand,
|
|
GetLayerVersionCommand,
|
|
ListLayerVersionsCommand,
|
|
ListLayersCommand,
|
|
DeleteLayerVersionCommand,
|
|
CreateFunctionCommand,
|
|
GetFunctionConfigurationCommand,
|
|
UpdateFunctionConfigurationCommand,
|
|
DeleteFunctionCommand,
|
|
} from '@aws-sdk/client-lambda';
|
|
import { makeClient, uniqueName, ACCOUNT, buildMinimalZip } from './setup';
|
|
|
|
describe('Lambda Layers', () => {
|
|
let lambda: LambdaClient;
|
|
let layerName: string;
|
|
let layerArn: string;
|
|
let layerVersionArn1: string;
|
|
let layerVersionArn2: string;
|
|
|
|
beforeAll(() => {
|
|
lambda = makeClient(LambdaClient);
|
|
layerName = `compat-layer-${uniqueName()}`;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
// Clean up any remaining layer versions
|
|
try {
|
|
await lambda.send(new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 1 }));
|
|
} catch { /* ignore */ }
|
|
try {
|
|
await lambda.send(new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 2 }));
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
it('should publish layer version 1', async () => {
|
|
const layerContent = Buffer.from('module.exports.util = () => "v1";');
|
|
const zipBuffer = buildMinimalZip('nodejs/node_modules/shared/index.js', layerContent);
|
|
|
|
const response = await lambda.send(
|
|
new PublishLayerVersionCommand({
|
|
LayerName: layerName,
|
|
Content: { ZipFile: zipBuffer },
|
|
CompatibleRuntimes: ['nodejs20.x', 'nodejs22.x'],
|
|
CompatibleArchitectures: ['x86_64'],
|
|
Description: 'Shared utilities v1',
|
|
LicenseInfo: 'MIT',
|
|
})
|
|
);
|
|
|
|
expect(response.Version).toBe(1);
|
|
expect(response.LayerArn).toContain(`:layer:${layerName}`);
|
|
expect(response.LayerVersionArn).toContain(`:layer:${layerName}:1`);
|
|
expect(response.Description).toBe('Shared utilities v1');
|
|
expect(response.LicenseInfo).toBe('MIT');
|
|
expect(response.CompatibleRuntimes).toContain('nodejs20.x');
|
|
expect(response.CompatibleRuntimes).toContain('nodejs22.x');
|
|
expect(response.CompatibleArchitectures).toContain('x86_64');
|
|
expect(response.Content?.CodeSize).toBeGreaterThan(0);
|
|
expect(response.Content?.CodeSha256).toBeTruthy();
|
|
expect(response.CreatedDate).toBeTruthy();
|
|
|
|
layerArn = response.LayerArn!;
|
|
layerVersionArn1 = response.LayerVersionArn!;
|
|
});
|
|
|
|
it('should publish layer version 2', async () => {
|
|
const layerContent = Buffer.from('module.exports.util = () => "v2";');
|
|
const zipBuffer = buildMinimalZip('nodejs/node_modules/shared/index.js', layerContent);
|
|
|
|
const response = await lambda.send(
|
|
new PublishLayerVersionCommand({
|
|
LayerName: layerName,
|
|
Content: { ZipFile: zipBuffer },
|
|
CompatibleRuntimes: ['nodejs22.x'],
|
|
Description: 'Shared utilities v2',
|
|
})
|
|
);
|
|
|
|
expect(response.Version).toBe(2);
|
|
expect(response.LayerVersionArn).toContain(`:layer:${layerName}:2`);
|
|
expect(response.Description).toBe('Shared utilities v2');
|
|
|
|
layerVersionArn2 = response.LayerVersionArn!;
|
|
});
|
|
|
|
it('should get layer version 1', async () => {
|
|
const response = await lambda.send(
|
|
new GetLayerVersionCommand({
|
|
LayerName: layerName,
|
|
VersionNumber: 1,
|
|
})
|
|
);
|
|
|
|
expect(response.Version).toBe(1);
|
|
expect(response.LayerArn).toBe(layerArn);
|
|
expect(response.LayerVersionArn).toBe(layerVersionArn1);
|
|
expect(response.Description).toBe('Shared utilities v1');
|
|
expect(response.Content?.CodeSha256).toBeTruthy();
|
|
expect(response.Content?.CodeSize).toBeGreaterThan(0);
|
|
});
|
|
|
|
it('should fail to get non-existent layer version', async () => {
|
|
await expect(
|
|
lambda.send(new GetLayerVersionCommand({ LayerName: layerName, VersionNumber: 99 }))
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should list layer versions', async () => {
|
|
const response = await lambda.send(
|
|
new ListLayerVersionsCommand({ LayerName: layerName })
|
|
);
|
|
|
|
expect(response.LayerVersions).toHaveLength(2);
|
|
expect(response.LayerVersions![0].Version).toBe(1);
|
|
expect(response.LayerVersions![1].Version).toBe(2);
|
|
});
|
|
|
|
it('should list layers and include our layer', async () => {
|
|
const response = await lambda.send(new ListLayersCommand({}));
|
|
|
|
const ourLayer = response.Layers?.find((l) => l.LayerName === layerName);
|
|
expect(ourLayer).toBeDefined();
|
|
expect(ourLayer!.LayerArn).toBe(layerArn);
|
|
expect(ourLayer!.LatestMatchingVersion?.Version).toBe(2);
|
|
});
|
|
|
|
it('should delete layer version 1', async () => {
|
|
// DeleteLayerVersion returns nothing on success (204)
|
|
await lambda.send(
|
|
new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 1 })
|
|
);
|
|
|
|
// Verify it's gone
|
|
await expect(
|
|
lambda.send(new GetLayerVersionCommand({ LayerName: layerName, VersionNumber: 1 }))
|
|
).rejects.toThrow();
|
|
});
|
|
|
|
it('should list layer versions after deletion', async () => {
|
|
const response = await lambda.send(
|
|
new ListLayerVersionsCommand({ LayerName: layerName })
|
|
);
|
|
|
|
expect(response.LayerVersions).toHaveLength(1);
|
|
expect(response.LayerVersions![0].Version).toBe(2);
|
|
});
|
|
|
|
it('should handle delete of non-existent version gracefully', async () => {
|
|
// AWS returns 204 even for non-existent versions
|
|
await lambda.send(
|
|
new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 99 })
|
|
);
|
|
});
|
|
});
|
|
|
|
describe('Lambda Layer Attachment', () => {
|
|
let lambda: LambdaClient;
|
|
let layerName: string;
|
|
let layerVersionArn: string;
|
|
let fnName: string;
|
|
|
|
beforeAll(async () => {
|
|
lambda = makeClient(LambdaClient);
|
|
layerName = `attach-layer-${uniqueName()}`;
|
|
fnName = `layer-fn-${uniqueName()}`;
|
|
|
|
// Publish a layer to attach
|
|
const layerContent = Buffer.from('module.exports.dep = () => "shared";');
|
|
const zipBuffer = buildMinimalZip('nodejs/node_modules/dep/index.js', layerContent);
|
|
|
|
const layerResp = await lambda.send(
|
|
new PublishLayerVersionCommand({
|
|
LayerName: layerName,
|
|
Content: { ZipFile: zipBuffer },
|
|
CompatibleRuntimes: ['nodejs20.x'],
|
|
})
|
|
);
|
|
layerVersionArn = layerResp.LayerVersionArn!;
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await lambda.send(new DeleteFunctionCommand({ FunctionName: fnName }));
|
|
} catch { /* ignore */ }
|
|
try {
|
|
await lambda.send(new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber: 1 }));
|
|
} catch { /* ignore */ }
|
|
});
|
|
|
|
it('should create function with layer attached', async () => {
|
|
const handlerCode = "exports.handler = async () => ({ statusCode: 200 });";
|
|
const zipBuffer = buildMinimalZip('index.js', Buffer.from(handlerCode));
|
|
|
|
const response = await lambda.send(
|
|
new CreateFunctionCommand({
|
|
FunctionName: fnName,
|
|
Runtime: 'nodejs20.x',
|
|
Role: `arn:aws:iam::${ACCOUNT}:role/lambda-role`,
|
|
Handler: 'index.handler',
|
|
Code: { ZipFile: zipBuffer },
|
|
Layers: [layerVersionArn],
|
|
})
|
|
);
|
|
|
|
expect(response.Layers).toHaveLength(1);
|
|
expect(response.Layers![0].Arn).toBe(layerVersionArn);
|
|
});
|
|
|
|
it('should show layers in function configuration', async () => {
|
|
const response = await lambda.send(
|
|
new GetFunctionConfigurationCommand({ FunctionName: fnName })
|
|
);
|
|
|
|
expect(response.Layers).toHaveLength(1);
|
|
expect(response.Layers![0].Arn).toBe(layerVersionArn);
|
|
});
|
|
|
|
it('should update function to remove layers', async () => {
|
|
const response = await lambda.send(
|
|
new UpdateFunctionConfigurationCommand({
|
|
FunctionName: fnName,
|
|
Layers: [],
|
|
})
|
|
);
|
|
|
|
// After removing layers, the field should be empty or absent
|
|
expect(response.Layers ?? []).toHaveLength(0);
|
|
});
|
|
|
|
it('should update function to add layers back', async () => {
|
|
const response = await lambda.send(
|
|
new UpdateFunctionConfigurationCommand({
|
|
FunctionName: fnName,
|
|
Layers: [layerVersionArn],
|
|
})
|
|
);
|
|
|
|
expect(response.Layers).toHaveLength(1);
|
|
expect(response.Layers![0].Arn).toBe(layerVersionArn);
|
|
});
|
|
});
|
|
|
|
describe('Lambda Layers listing parameters', () => {
|
|
let lambda: LambdaClient;
|
|
let layerName: string;
|
|
|
|
const zip = (marker: string) =>
|
|
buildMinimalZip('nodejs/node_modules/shared/index.js', Buffer.from(marker));
|
|
|
|
beforeAll(async () => {
|
|
lambda = makeClient(LambdaClient);
|
|
layerName = `compat-layer-params-${uniqueName()}`;
|
|
|
|
// v1 nodejs20.x/x86_64, v2 python3.13/arm64, v3 nodejs20.x/arm64: every filter
|
|
// combination below then selects a different subset of the same layer.
|
|
await lambda.send(
|
|
new PublishLayerVersionCommand({
|
|
LayerName: layerName,
|
|
Content: { ZipFile: zip('v1') },
|
|
CompatibleRuntimes: ['nodejs20.x'],
|
|
CompatibleArchitectures: ['x86_64'],
|
|
})
|
|
);
|
|
await lambda.send(
|
|
new PublishLayerVersionCommand({
|
|
LayerName: layerName,
|
|
Content: { ZipFile: zip('v2') },
|
|
CompatibleRuntimes: ['python3.13'],
|
|
CompatibleArchitectures: ['arm64'],
|
|
})
|
|
);
|
|
await lambda.send(
|
|
new PublishLayerVersionCommand({
|
|
LayerName: layerName,
|
|
Content: { ZipFile: zip('v3') },
|
|
CompatibleRuntimes: ['nodejs20.x'],
|
|
CompatibleArchitectures: ['arm64'],
|
|
})
|
|
);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
for (const VersionNumber of [1, 2, 3]) {
|
|
try {
|
|
await lambda.send(new DeleteLayerVersionCommand({ LayerName: layerName, VersionNumber }));
|
|
} catch { /* ignore */ }
|
|
}
|
|
});
|
|
|
|
it('filters listLayerVersions by CompatibleRuntime', async () => {
|
|
const response = await lambda.send(
|
|
new ListLayerVersionsCommand({ LayerName: layerName, CompatibleRuntime: 'python3.13' })
|
|
);
|
|
|
|
expect(response.LayerVersions?.map((v) => v.Version)).toEqual([2]);
|
|
});
|
|
|
|
it('filters listLayerVersions by CompatibleArchitecture', async () => {
|
|
const response = await lambda.send(
|
|
new ListLayerVersionsCommand({ LayerName: layerName, CompatibleArchitecture: 'arm64' })
|
|
);
|
|
|
|
expect(response.LayerVersions?.map((v) => v.Version).sort()).toEqual([2, 3]);
|
|
});
|
|
|
|
it('applies both filters together', async () => {
|
|
const response = await lambda.send(
|
|
new ListLayerVersionsCommand({
|
|
LayerName: layerName,
|
|
CompatibleRuntime: 'nodejs20.x',
|
|
CompatibleArchitecture: 'arm64',
|
|
})
|
|
);
|
|
|
|
expect(response.LayerVersions?.map((v) => v.Version)).toEqual([3]);
|
|
});
|
|
|
|
it('pages listLayerVersions with MaxItems and resumes from NextMarker', async () => {
|
|
const seen: number[] = [];
|
|
let marker: string | undefined;
|
|
let pages = 0;
|
|
|
|
do {
|
|
const page = await lambda.send(
|
|
new ListLayerVersionsCommand({ LayerName: layerName, MaxItems: 1, Marker: marker })
|
|
);
|
|
expect(page.LayerVersions).toHaveLength(1);
|
|
seen.push(page.LayerVersions![0].Version!);
|
|
marker = page.NextMarker;
|
|
pages += 1;
|
|
} while (marker && pages < 5);
|
|
|
|
// The marker walks every version exactly once and clears on the last page.
|
|
expect(marker).toBeFalsy();
|
|
expect(seen.sort()).toEqual([1, 2, 3]);
|
|
});
|
|
|
|
it('reports the newest matching version as LatestMatchingVersion, not the newest overall', async () => {
|
|
const python = await lambda.send(
|
|
new ListLayersCommand({ CompatibleRuntime: 'python3.13' })
|
|
);
|
|
const node = await lambda.send(new ListLayersCommand({ CompatibleRuntime: 'nodejs20.x' }));
|
|
|
|
expect(
|
|
python.Layers?.find((l) => l.LayerName === layerName)?.LatestMatchingVersion?.Version
|
|
).toBe(2);
|
|
expect(
|
|
node.Layers?.find((l) => l.LayerName === layerName)?.LatestMatchingVersion?.Version
|
|
).toBe(3);
|
|
});
|
|
|
|
it('drops a layer with no matching version out of listLayers', async () => {
|
|
const response = await lambda.send(new ListLayersCommand({ CompatibleRuntime: 'java21' }));
|
|
|
|
expect(response.Layers?.some((l) => l.LayerName === layerName)).toBe(false);
|
|
});
|
|
|
|
it('rejects an unknown CompatibleRuntime with ValidationException', async () => {
|
|
await expect(
|
|
lambda.send(new ListLayersCommand({ CompatibleRuntime: 'bogus9.x' }))
|
|
).rejects.toMatchObject({ name: 'ValidationException' });
|
|
});
|
|
});
|