Skip to content

Commit

Permalink
feat(datasource): add aws-eks datasource
Browse files Browse the repository at this point in the history
Signed-off-by: ivan katliarchuk <[email protected]>
  • Loading branch information
ivankatliarchuk committed Dec 27, 2024
1 parent 8d31e5c commit 53f21e2
Show file tree
Hide file tree
Showing 9 changed files with 919 additions and 8 deletions.
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
137 changes: 137 additions & 0 deletions lib/modules/datasource/aws-eks/index.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,137 @@
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);

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)),
},
],
};

eksMock.on(DescribeClusterVersionsCommand).resolves(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',
},
],
};
eksMock.on(DescribeClusterVersionsCommand).resolves(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,
},
],
};
eksMock.on(DescribeClusterVersionsCommand).resolves(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: [],
};
eksMock.on(DescribeClusterVersionsCommand).resolves(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();
});
});
});
76 changes: 76 additions & 0 deletions lib/modules/datasource/aws-eks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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}}}',
};

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, 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];
}
}
124 changes: 124 additions & 0 deletions lib/modules/datasource/aws-eks/readme.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
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"}
```
```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" // aws-eks versioning is not yet supported
}
]
}
```

Check failure on line 79 in lib/modules/datasource/aws-eks/readme.md

View workflow job for this annotation

GitHub Actions / lint-docs

Invalid JSON in fenced code block

Expected double-quoted property name in JSON at position 416 (line 15 column 7). Fix this manually by ensuring each block is a valid, complete JSON document.
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
```
21 changes: 21 additions & 0 deletions lib/modules/datasource/aws-eks/schema.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import { EksFilter } from './schema';

describe('modules/datasource/aws-eks/schema', () => {
describe('EksFilter', () => {
it.each`
input | expected
${{ default: 'false' }} | ${true}
${{ default: 'true' }} | ${true}
${{ default: false }} | ${true}
${{ default: true }} | ${true}
${{}} | ${true}
${{ default: 'false', region: 'eu-west-1' }} | ${true}
${{ region: 'us-gov-west-1', profile: 'gov-profile' }} | ${true}
${{ region: 'us-gov-west-not-exist' }} | ${false}
${{ default: 'abrakadabra' }} | ${true}
`('EksFilter.safeParse("$input") === $expected', ({ input, expected }) => {
const actual = EksFilter.safeParse(JSON.stringify(input));
expect(actual.success).toBe(expected);
});
});
});
Loading

0 comments on commit 53f21e2

Please sign in to comment.