Files
Gregor Jasny 325dc6407d fix(datasource/aws-machine-image): return all AMI releases for minimumReleaseAge filtering (#43749)
* fix(datasource/aws-machine-image): return all AMI releases for minimumReleaseAge filtering

Previously, _getReleases only returned the latest image in the releases array,
which broke minimumReleaseAge filtering. When the latest AMI was too new,
there were no older candidates to fall back to.

Fixed by:
- _getReleases now returns all matching AMIs as separate releases (sorted oldest→newest)
- _getDigest simplified to read directly from the images array to ensure latest digest is still returned
- Added reproducer test verifying all 3 images are returned

This aligns with how other datasources (npm, PyPI, etc.) work — they return
complete version history to allow minimumReleaseAge filtering to find an
appropriately-aged candidate.

Co-authored-by: Claude Code <claude@anthropic.com>

* chore: apply suggestion

* fixup! chore: apply suggestion

---------

Co-authored-by: Claude Code <claude@anthropic.com>
2026-09-07 13:36:56 +00:00

384 lines
12 KiB
TypeScript

import type { DescribeImagesResult, Image } from '@aws-sdk/client-ec2';
import { DescribeImagesCommand, EC2Client } from '@aws-sdk/client-ec2';
import { mockClient } from 'aws-sdk-client-mock';
import * as hostRules from '../../../util/host-rules.ts';
import { getDigest, getPkgReleases } from '../index.ts';
import { AwsMachineImageDatasource } from './index.ts';
const datasource = AwsMachineImageDatasource.id;
const ec2Mock = mockClient(EC2Client);
/**
* Testdata for mock implementation of EC2Client
* image1 to image3 from oldest to newest image
*/
const image1: Image = {
Architecture: 'x86_64',
CreationDate: '2021-08-13T17:47:12.000Z',
ImageId: 'ami-02ce3d9008cab69cb',
ImageLocation: 'amazon/amazon-eks-node-1.21-v20210813',
ImageType: 'machine',
Public: true,
OwnerId: '602401143452',
PlatformDetails: 'Linux/UNIX',
UsageOperation: 'RunInstances',
State: 'available',
BlockDeviceMappings: [
{
DeviceName: '/dev/xvda',
Ebs: {
DeleteOnTermination: true,
SnapshotId: 'snap-0546beb61976e8017',
VolumeSize: 20,
VolumeType: 'gp2',
Encrypted: false,
},
},
],
Description:
'EKS Kubernetes Worker AMI with AmazonLinux2 image, (k8s: 1.21.2, docker: 19.03.13ce-1.amzn2, containerd: 1.4.6-2.amzn2)',
EnaSupport: true,
Hypervisor: 'xen',
ImageOwnerAlias: 'amazon',
Name: 'amazon-eks-node-1.21-v20210813',
RootDeviceName: '/dev/xvda',
RootDeviceType: 'ebs',
SriovNetSupport: 'simple',
VirtualizationType: 'hvm',
};
const image2: Image = {
Architecture: 'x86_64',
CreationDate: '2021-08-26T19:31:41.000Z',
DeprecationTime: '2021-08-14T17:47:12.000Z',
ImageId: 'ami-020d418c09883b165',
ImageLocation: 'amazon/amazon-eks-node-1.21-v20210826',
ImageType: 'machine',
Public: true,
OwnerId: '602401143452',
PlatformDetails: 'Linux/UNIX',
UsageOperation: 'RunInstances',
State: 'available',
BlockDeviceMappings: [
{
DeviceName: '/dev/xvda',
Ebs: {
DeleteOnTermination: true,
SnapshotId: 'snap-01ba16a8ec8087603',
VolumeSize: 20,
VolumeType: 'gp2',
Encrypted: false,
},
},
],
Description:
'EKS Kubernetes Worker AMI with AmazonLinux2 image, (k8s: 1.21.2, docker: 19.03.13ce-1.amzn2, containerd: 1.4.6-2.amzn2)',
EnaSupport: true,
Hypervisor: 'xen',
ImageOwnerAlias: 'amazon',
Name: 'amazon-eks-node-1.21-v20210826',
RootDeviceName: '/dev/xvda',
RootDeviceType: 'ebs',
SriovNetSupport: 'simple',
VirtualizationType: 'hvm',
};
const image3: Image = {
Architecture: 'x86_64',
CreationDate: '2021-09-14T22:00:24.000Z',
ImageId: 'ami-05f83986b0fe58ada',
ImageLocation: 'amazon/amazon-eks-node-1.21-v20210914',
ImageType: 'machine',
Public: true,
OwnerId: '602401143452',
PlatformDetails: 'Linux/UNIX',
UsageOperation: 'RunInstances',
State: 'available',
BlockDeviceMappings: [
{
DeviceName: '/dev/xvda',
Ebs: {
DeleteOnTermination: true,
SnapshotId: 'snap-0c6f79c3983fd8e1a',
VolumeSize: 20,
VolumeType: 'gp2',
Encrypted: false,
},
},
],
Description:
'EKS Kubernetes Worker AMI with AmazonLinux2 image, (k8s: 1.21.2, docker: 19.03.13ce-1.amzn2, containerd: 1.4.6-2.amzn2)',
EnaSupport: true,
Hypervisor: 'xen',
ImageOwnerAlias: 'amazon',
Name: 'amazon-eks-node-1.21-v20210914',
RootDeviceName: '/dev/xvda',
RootDeviceType: 'ebs',
SriovNetSupport: 'simple',
VirtualizationType: 'hvm',
};
const mock3Images: DescribeImagesResult = {
Images: [image3, image1, image2],
};
const mock1Image: DescribeImagesResult = {
Images: [image3],
};
const mockEmpty: DescribeImagesResult = {};
function mockDescribeImagesCommand(result: DescribeImagesResult): void {
ec2Mock.reset();
ec2Mock.on(DescribeImagesCommand).resolves(result);
}
describe('modules/datasource/aws-machine-image/index', () => {
beforeEach(() => {
hostRules.clear();
});
describe('getSortedAwsMachineImages()', () => {
it('with 3 returned images', async () => {
mockDescribeImagesCommand(mock3Images);
const ec2DataSource = new AwsMachineImageDatasource();
const res = await ec2DataSource.getSortedAwsMachineImages(
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["3images"]}]',
);
expect(res).toStrictEqual([image1, image2, image3]);
expect(ec2Mock.calls()).toHaveLength(1);
});
it('with 1 returned image', async () => {
mockDescribeImagesCommand(mock1Image);
const ec2DataSource = new AwsMachineImageDatasource();
const res = await ec2DataSource.getSortedAwsMachineImages(
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["1image"]}]',
);
expect(res).toStrictEqual([image3]);
expect(ec2Mock.calls()).toHaveLength(1);
});
it('prefers host rule credentials over the configured profile', async () => {
hostRules.add({
hostType: datasource,
username: 'access-key-id',
password: 'secret-access-key',
token: 'session-token',
});
mockDescribeImagesCommand(mock1Image);
const ec2DataSource = new AwsMachineImageDatasource();
await ec2DataSource.getSortedAwsMachineImages(
'[{"Name":"name","Values":["host-rule-credentials"]},{"profile":"ignored-profile"}]',
);
const ec2 = ec2Mock.call(0).thisValue as EC2Client;
await expect(ec2.config.credentials()).resolves.toEqual({
accessKeyId: 'access-key-id',
secretAccessKey: 'secret-access-key',
sessionToken: 'session-token',
$source: {
CREDENTIALS_CODE: 'e',
},
});
});
it('without returned images', async () => {
mockDescribeImagesCommand(mockEmpty);
const ec2DataSource = new AwsMachineImageDatasource();
const res = await ec2DataSource.getSortedAwsMachineImages(
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["noiamge"]}]',
);
expect(res).toStrictEqual([]);
expect(ec2Mock.calls()).toHaveLength(1);
});
});
describe('getDigest()', () => {
it('without newValue, without returned images to be null', async () => {
mockDescribeImagesCommand(mockEmpty);
const res = await getDigest({
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["without newValue, without returned images to be null"]}]',
});
expect(res).toBeNull();
});
it('without newValue, with one matching image to return that image', async () => {
mockDescribeImagesCommand(mock1Image);
const res = await getDigest({
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["without newValue, with one matching image to return that image"]}]',
});
expect(res).toStrictEqual(image3.Name);
});
it('without newValue, with newest image missing a name to be null', async () => {
const { Name, ...imageWithoutName } = image3;
mockDescribeImagesCommand({ Images: [imageWithoutName] });
const res = await getDigest({
datasource,
packageName: '[{"Name":"owner-id","Values":["602401143452"]}]',
});
expect(res).toBeNull();
});
it('without newValue, with 3 matching image to return the newest image', async () => {
mockDescribeImagesCommand(mock3Images);
const res = await getDigest({
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["without newValue, with 3 matching image to return the newest image"]}]',
});
expect(res).toStrictEqual(image3.Name);
});
it('with matching newValue, with 3 matching image to return the matching image', async () => {
mockDescribeImagesCommand(mock3Images);
const res = await getDigest(
{
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["with matching newValue, with 3 matching image to return the matching image"]}]',
},
image1.ImageId,
);
expect(res).toStrictEqual(image1.Name);
});
it('with not matching newValue, with 3 matching images to return the matching image', async () => {
mockDescribeImagesCommand(mock3Images);
const res = await getDigest(
{
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["with not matching newValue, with 3 matching images to return the matching image"]}]',
},
'will never match',
);
expect(res).toBeNull();
});
});
describe('getPkgReleases()', () => {
it('without returned images to be null', async () => {
mockDescribeImagesCommand(mockEmpty);
const res = await getPkgReleases({
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["without returned images to be null"]}]',
});
expect(res).toBeNull();
});
it('with one matching image to return that image', async () => {
mockDescribeImagesCommand(mock1Image);
const res = await getPkgReleases({
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["with one matching image to return that image"]}]',
});
expect(res).toEqual({
releases: [
{
isDeprecated: false,
newDigest: image3.Name,
releaseTimestamp: image3.CreationDate,
version: image3.ImageId,
},
],
});
});
it('with one deprecated matching image to return that image', async () => {
mockDescribeImagesCommand({ Images: [image2] });
const res = await getPkgReleases({
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["with one deprecated matching image to return that image"]}]',
});
expect(res).toEqual({
releases: [
{
isDeprecated: true,
newDigest: image2.Name,
releaseTimestamp: image2.CreationDate,
version: image2.ImageId,
},
],
});
});
it('with 3 matching images returns all images as releases, not just the newest', async () => {
mockDescribeImagesCommand(mock3Images);
const res = await getPkgReleases({
datasource,
packageName:
'[{"Name":"owner-id","Values":["602401143452"]},{"Name":"name","Values":["with 3 matching images returns all"]}]',
});
expect(res).toEqual({
releases: [
{
isDeprecated: false,
newDigest: image1.Name,
releaseTimestamp: image1.CreationDate,
version: image1.ImageId,
},
{
isDeprecated: true,
newDigest: image2.Name,
releaseTimestamp: image2.CreationDate,
version: image2.ImageId,
},
{
isDeprecated: false,
newDigest: image3.Name,
releaseTimestamp: image3.CreationDate,
version: image3.ImageId,
},
],
});
});
});
describe('loadConfig()', () => {
const ec2DataSource = new AwsMachineImageDatasource();
it('loads filters without aws config', () => {
const res = ec2DataSource.loadConfig(
'[{"Name":"testname","Values":["testvalue"]}]',
);
expect(res).toEqual([
[
{
Name: 'testname',
Values: ['testvalue'],
},
],
{},
]);
});
it('loads filters with multiple aws configs', () => {
const res = ec2DataSource.loadConfig(
'[{"Name":"testname","Values":["testvalue"]},{"region":"us-west-2"},{"profile":"test-profile"},{"region":"eu-central-1"}]',
);
expect(res).toEqual([
[
{
Name: 'testname',
Values: ['testvalue'],
},
],
{
region: 'eu-central-1',
profile: 'test-profile',
},
]);
});
});
});