Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(datasource): add aws-eks datasource #33305

Open
wants to merge 8 commits into
base: main
Choose a base branch
from
2 changes: 2 additions & 0 deletions lib/modules/datasource/api.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { ArtifactoryDatasource } from './artifactory';
import { AwsEKSDataSource } from './aws-eks';
import { AwsMachineImageDatasource } from './aws-machine-image';
import { AwsRdsDatasource } from './aws-rds';
import { AzureBicepResourceDatasource } from './azure-bicep-resource';
Expand Down Expand Up @@ -70,6 +71,7 @@ const api = new Map<string, DatasourceApi>();
export default api;

api.set(ArtifactoryDatasource.id, new ArtifactoryDatasource());
api.set(AwsEKSDataSource.id, new AwsEKSDataSource());
api.set(AwsMachineImageDatasource.id, new AwsMachineImageDatasource());
api.set(AwsRdsDatasource.id, new AwsRdsDatasource());
api.set(AzureBicepResourceDatasource.id, new AzureBicepResourceDatasource());
Expand Down
171 changes: 171 additions & 0 deletions lib/modules/datasource/aws-eks/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
import {
DescribeClusterVersionsCommand,
type DescribeClusterVersionsCommandOutput,
EKSClient,
} from '@aws-sdk/client-eks';
import { mockClient } from 'aws-sdk-client-mock';
import { logger } from '../../../../test/util';
import { getPkgReleases } from '../index';
import { AwsEKSDataSource } from '.';

const datasource = AwsEKSDataSource.id;
const eksMock = mockClient(EKSClient);

function mockClusterVersionsResponse(
input: DescribeClusterVersionsCommandOutput,
) {
return eksMock.on(DescribeClusterVersionsCommand).resolves(input);
}

describe('modules/datasource/aws-eks/index', () => {
beforeEach(() => {
eksMock.reset();
});

describe('getReleases()', () => {
it('should return releases when the response is valid', async () => {
const mockResponse: DescribeClusterVersionsCommandOutput = {
$metadata: {},
clusterVersions: [
{
clusterVersion: '1.21',
releaseDate: new Date(
new Date().setMonth(new Date().getMonth() - 24),
),
endOfStandardSupportDate: new Date(
new Date().setMonth(new Date().getMonth() + 10),
),
},
],
};

mockClusterVersionsResponse(mockResponse);

const result = await getPkgReleases({ datasource, packageName: '{}' });

expect(result?.releases).toHaveLength(1);
expect(result).toEqual({
releases: [
{
version: '1.21',
},
],
});

expect(eksMock.calls()).toHaveLength(1);
expect(eksMock.call(0).args[0].input).toEqual({});
});

it('should return null and log an error when the filter is invalid', async () => {
const invalidFilter = '{ invalid json }';
const actual = await getPkgReleases({
datasource,
packageName: invalidFilter,
});
expect(actual).toBeNull();
expect(logger.logger.error).toHaveBeenCalledTimes(1);
});

it('should return default cluster only', async () => {
const mockResponse: DescribeClusterVersionsCommandOutput = {
$metadata: {},
clusterVersions: [
{
clusterVersion: '1.31',
defaultVersion: true,
status: 'standard-support',
},
],
};
mockClusterVersionsResponse(mockResponse);

const actual = await getPkgReleases({
datasource,
packageName: '{"default":"true", "region":"eu-west-1"}',
});

expect(eksMock.calls()).toHaveLength(1);
expect(eksMock.call(0).args[0].input).toEqual({ defaultOnly: true });

expect(actual).toEqual({
releases: [
{
version: '1.31',
},
],
});
});

it('should return default and non-default cluster when default:false', async () => {
const mockResponse: DescribeClusterVersionsCommandOutput = {
$metadata: {},
clusterVersions: [
{
clusterVersion: '1.31',
defaultVersion: true,
},
{
clusterVersion: '1.30',
defaultVersion: false,
},
{
clusterVersion: '1.29',
defaultVersion: false,
},
],
};
mockClusterVersionsResponse(mockResponse);

const actual = await getPkgReleases({
datasource,
packageName:
'{"default":"false", "region":"eu-west-1", "profile":"admin"}',
});

expect(eksMock.calls()).toHaveLength(1);
expect(eksMock.call(0).args[0].input).toEqual({ defaultOnly: false });

expect(actual).toEqual({
releases: [
{ version: '1.29' },
{ version: '1.30' },
{ version: '1.31' },
],
});
});

it('should return empty response', async () => {
const mockResponse: DescribeClusterVersionsCommandOutput = {
$metadata: {},
clusterVersions: [],
};
mockClusterVersionsResponse(mockResponse);

const actual = await getPkgReleases({
datasource,
packageName: '{"profile":"not-exist-profile"}',
});

expect(eksMock.calls()).toHaveLength(1);
expect(eksMock.call(0).args[0].input).toEqual({});
expect(actual).toBeNull();
});

it('should return undefined response', async () => {
const mockResponse: DescribeClusterVersionsCommandOutput = {
$metadata: {},
clusterVersions: undefined,
};
mockClusterVersionsResponse(mockResponse);

const actual = await getPkgReleases({
datasource,
packageName: '{"profile":"not-exist-profile"}',
});

expect(eksMock.calls()).toHaveLength(1);
expect(eksMock.call(0).args[0].input).toEqual({});
expect(actual).toBeNull();
});
});
});
84 changes: 84 additions & 0 deletions lib/modules/datasource/aws-eks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import {
type ClusterVersionInformation,
DescribeClusterVersionsCommand,
type DescribeClusterVersionsCommandInput,
type DescribeClusterVersionsCommandOutput,
EKSClient,
} from '@aws-sdk/client-eks';

import { fromNodeProviderChain } from '@aws-sdk/credential-providers';
import { logger } from '../../../logger';
import { cache } from '../../../util/cache/package/decorator';
import { Datasource } from '../datasource';
import type { GetReleasesConfig, ReleaseResult } from '../types';
import { EksFilter } from './schema';

export class AwsEKSDataSource extends Datasource {
static readonly id = 'aws-eks';

override readonly caching = true;
private readonly clients: Record<string, EKSClient> = {};

override readonly releaseTimestampSupport = true;
override readonly releaseTimestampNote =
'The release timestamp is determined from the `endOfStandardSupportDate` field in the results.';

override readonly defaultConfig: Record<string, unknown> | undefined = {
commitMessageTopic: '{{{datasource}}}',
commitMessageExtra: '{{{currentVersion}}} to {{{newVersion}}}',
prBodyDefinitions: {
Package: '```{{{datasource}}}```',
},
};

constructor() {
super(AwsEKSDataSource.id);
}

@cache({
namespace: `datasource-${AwsEKSDataSource.id}`,
key: ({ packageName }: GetReleasesConfig) => `getReleases:${packageName}`,
})
async getReleases({
packageName: serializedFilter,
}: GetReleasesConfig): Promise<ReleaseResult | null> {
const res = EksFilter.safeParse(serializedFilter);
if (!res.success) {
logger.error(
{ err: res.error.message, serializedFilter },
'Error parsing eks config.',
);
return null;
}

const input: DescribeClusterVersionsCommandInput = {
defaultOnly: res.data.default ?? undefined,
};
const cmd = new DescribeClusterVersionsCommand(input);
const response: DescribeClusterVersionsCommandOutput = await this.getClient(
res.data,
).send(cmd);
const results: ClusterVersionInformation[] = response.clusterVersions ?? [];
return {
releases: results
.filter(
(el): el is ClusterVersionInformation & { clusterVersion: string } =>
Boolean(el.clusterVersion),
)
.map((el) => ({
version: el.clusterVersion,
})),
};
}

private getClient({ region, profile }: EksFilter): EKSClient {
const cacheKey = `${region ?? 'default'}#${profile ?? 'default'}`;
if (!(cacheKey in this.clients)) {
this.clients[cacheKey] = new EKSClient({
region: region ?? undefined,
credentials: fromNodeProviderChain(profile ? { profile } : undefined),
});
}
return this.clients[cacheKey];
}
}
120 changes: 120 additions & 0 deletions lib/modules/datasource/aws-eks/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
The EKS `datasource` is designed to query one or more [AWS EKS](https://docs.aws.amazon.com/eks/latest/userguide/platform-versions.html) via the AWS API.

**AWS API configuration**

Since the datasource uses the AWS SDK for JavaScript, you can configure it like other AWS Tools.
You can use common AWS configuration options, for example:

- Specifies the AWS region where your resources are located. This is crucial for routing requests to the correct endpoint.
- Set the region via the `AWS_REGION` environment variable
- Pass the `region` option to Renovate
- Read credentials from environment variables (e.g., `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`).
- Load credentials from the shared credentials file (~/.aws/credentials).
- Use IAM roles for EC2 instances or Lambda functions.
- A chain of credential providers that the SDK attempts in order.

The minimal IAM privileges required for this datasource are:

```json
{
"Effect": "Allow",
"Action": ["eks:DescribeClusterVersions"],
"Resource": "*"
}
```

Read the [AWS EKS IAM reference](https://docs.aws.amazon.com/service-authorization/latest/reference/list_amazonelastickubernetesservice.html) for more information.

**Usage**

Because Renovate has no manager for the AWS EKS datasource, you need to help Renovate by configuring the custom manager to identify the AWS EKS configuration you want updated.

Configuration Options

```yaml
# discover all available eks versions.
renovate: eksFilter={}

# discover default eks versions
renovate: eksFilter={"default":true}

# discover all available eks versions in us-east-1 region using environmental AWS credentials. Region is a recommended option.
renovate: eksFilter={"region":"eu-west-1"}

# discover all available eks versions in us-east-1 region using AWS credentials from `renovate-east` profile.
renovate: eksFilter={"region":"us-east-1","profile":"renovate-east"}
```

Example configuration

```json
{
"packageRules": [
{
"matchDatasources": ["aws-eks"],
"prBodyColumns": ["Package", "Update", "Change", "Sources", "Changelog"],
"prBodyDefinitions": {
"Sources": "[▶️](https://github.com/aws/eks-distro/)",
"Changelog": "[▶️](https://github.com/kubernetes/kubernetes/blob/master/CHANGELOG/CHANGELOG-{{{newVersion}}}.md)"
}
}
],
"customManagers": [
{
"customType": "regex",
"fileMatch": [".*\\.tf"],
"matchStrings": [
".*# renovate: eksFilter=(?<packageName>.*?)\n.*?[a-zA-Z0-9-_:]*[ ]*?[:|=][ ]*?[\"|']?(?<currentValue>[a-zA-Z0-9-_.]+)[\"|']?.*"
],
"datasourceTemplate": "aws-eks",
"versioningTemplate": "loose"
}
]
}
```

The configuration above matches every terraform file, and recognizes these lines:

```hcl
variable "eks_version" {
type = string
description = "EKS vpc-cni add-on version"
# region provided
# renovate: eksFilter={"region":"eu-west-1"}
default = "1.24"
}
```

```yml
clusters:
- name: main
# only default version
# renovate: eksFilter={"default":true}
version: 1.24
- name: tier-1
# region and where or not only default versions
# renovate: eksFilter={"default":"false", "region":"eu-west-1"}
version: 1.28
```

**Cluster Upgrade**

- [AWS EKS cluster upgrade best practices](https://docs.aws.amazon.com/eks/latest/best-practices/cluster-upgrades.html)

At the moment there is no `aws-eks` versioning. The recommended approach is to upgrade to next minor version

When performing an in-place cluster upgrade, it is important to note that only one minor version upgrade can be executed at a time (e.g., from 1.24 to 1.25). This means that if you need to update multiple versions, a series of sequential upgrades will be required.

Correct

```diff
- version: 1.24
+ version: 1.25
```

Will not work

```diff
- version: 1.24
+ version: 1.27
```
Loading
Loading