retirement-radar 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Het Patel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,91 @@
1
+ <p align="center"><img src="https://raw.githubusercontent.com/Het101/retirement-radar/main/docs/banner.png" alt="Retirement Radar: find AWS resources before end of support costs you money" width="100%"></p>
2
+
3
+ <p align="center">
4
+ <a href="https://www.npmjs.com/package/retirement-radar"><img src="https://img.shields.io/npm/v/retirement-radar?color=E5484D&label=npm" alt="npm version"></a>
5
+ <a href="LICENSE"><img src="https://img.shields.io/badge/license-MIT-3f4756" alt="MIT license"></a>
6
+ <img src="https://img.shields.io/badge/AWS-read--only-3f4756" alt="Read-only">
7
+ </p>
8
+
9
+ # Retirement Radar
10
+
11
+ Find what in your AWS account is about to lose support before it starts costing you money or gets force-upgraded.
12
+
13
+ ```bash
14
+ npx retirement-radar scan
15
+ ```
16
+
17
+ <img src="https://raw.githubusercontent.com/Het101/retirement-radar/main/docs/scan-output.png" alt="Example output: a table of resources with status RETIRED, EXTENDED, SOON or UPCOMING, the end-of-support date and what it means, plus the monthly EKS extended support cost" width="100%">
18
+
19
+ <sub>Example output with sample data.</sub>
20
+
21
+ ## What it checks
22
+
23
+ | Service | What | Why it matters |
24
+ |---|---|---|
25
+ | EKS | Kubernetes version per cluster | After standard support, clusters move to extended support at 6x the control-plane price, then get force-upgraded |
26
+ | RDS / Aurora | Engine major version (PostgreSQL, MySQL, Aurora) | After standard support, RDS bills Extended Support per vCPU-hour automatically |
27
+ | Lambda | Function runtime | Deprecated runtimes get no security patches, then can't be created or updated |
28
+
29
+ Dates live in [`retirements.yaml`](retirements.yaml), each section with its AWS source link. A version that isn't listed shows as `unknown`, never as fine.
30
+
31
+ ## Run it
32
+
33
+ You need Node 18+ and the [AWS CLI v2](https://docs.aws.amazon.com/cli/latest/userguide/getting-started-install.html), signed in (`aws configure`, `aws sso login`, or an assumed role). Retirement Radar uses your AWS CLI, so profiles, SSO and roles just work.
34
+
35
+ ```bash
36
+ npx retirement-radar scan
37
+ ```
38
+
39
+ | Option | |
40
+ |---|---|
41
+ | `--region eu-west-1,us-east-1` | Only these regions (default: every enabled region) |
42
+ | `--profile prod` | AWS CLI profile |
43
+ | `--json` | Machine-readable output |
44
+ | `--all` | Also list resources with more than a year left |
45
+ | `--fail-on soon` | Exit 1 if anything is `retired`, `extended` or `soon` (levels: `retired`, `extended`, `soon`, `upcoming`) |
46
+ | `--data my.yaml` | Use your own dates file |
47
+
48
+ ## Safe by design
49
+
50
+ - **Read-only.** It only calls `sts get-caller-identity`, `ec2 describe-regions`, `eks list-clusters` / `describe-cluster`, `rds describe-db-instances` / `describe-db-clusters` and `lambda list-functions`.
51
+ - **Local.** Nothing is sent anywhere; no telemetry.
52
+ - A region or service you can't read becomes a warning, not a failed scan.
53
+
54
+ Minimal IAM policy:
55
+
56
+ ```json
57
+ {
58
+ "Version": "2012-10-17",
59
+ "Statement": [{
60
+ "Effect": "Allow",
61
+ "Action": ["ec2:DescribeRegions", "eks:ListClusters", "eks:DescribeCluster",
62
+ "rds:DescribeDBInstances", "rds:DescribeDBClusters", "lambda:ListFunctions"],
63
+ "Resource": "*"
64
+ }]
65
+ }
66
+ ```
67
+
68
+ ## In CI
69
+
70
+ ```yaml
71
+ # .github/workflows/retirement-radar.yml - weekly, fails when something needs action within 90 days
72
+ on: { schedule: [{ cron: "0 8 * * 1" }], workflow_dispatch: {} }
73
+ permissions: { id-token: write, contents: read }
74
+ jobs:
75
+ scan:
76
+ runs-on: ubuntu-latest
77
+ steps:
78
+ - uses: aws-actions/configure-aws-credentials@v4
79
+ with: { role-to-assume: arn:aws:iam::123456789012:role/retirement-radar, aws-region: us-east-1 }
80
+ - run: npx retirement-radar scan --fail-on soon
81
+ ```
82
+
83
+ ## Contributing dates
84
+
85
+ Found a wrong or missing date? Edit `retirements.yaml`, include the AWS source link in the PR, and run `npm test`.
86
+
87
+ ## Roadmap
88
+
89
+ Azure and GCP retirements, ElastiCache / OpenSearch / MSK versions, hosted weekly scans with alerts, cost per finding. Part of [HetOps](https://hetops.dev).
90
+
91
+ MIT licensed.
package/package.json ADDED
@@ -0,0 +1,36 @@
1
+ {
2
+ "name": "retirement-radar",
3
+ "version": "0.1.1",
4
+ "description": "Read-only scan of your AWS account for EKS, RDS and Lambda versions near end of support.",
5
+ "bin": {
6
+ "retirement-radar": "src/cli.js"
7
+ },
8
+ "files": [
9
+ "src",
10
+ "retirements.yaml"
11
+ ],
12
+ "scripts": {
13
+ "test": "node --test",
14
+ "prepublishOnly": "node --test"
15
+ },
16
+ "engines": {
17
+ "node": ">=18"
18
+ },
19
+ "keywords": [
20
+ "aws",
21
+ "eks",
22
+ "rds",
23
+ "lambda",
24
+ "end-of-life",
25
+ "deprecation",
26
+ "devops"
27
+ ],
28
+ "repository": "github:Het101/retirement-radar",
29
+ "homepage": "https://hetops.dev",
30
+ "license": "MIT",
31
+ "dependencies": {
32
+ "yaml": "^2.5.0"
33
+ },
34
+ "author": "Het Patel (https://hetops.dev)",
35
+ "bugs": "https://github.com/Het101/retirement-radar/issues"
36
+ }
@@ -0,0 +1,68 @@
1
+ # End-of-support dates Retirement Radar checks against.
2
+ # Every section links its source. Found a wrong or missing date? Open a PR with the source link.
3
+ # Versions not listed here show up as "unknown", never as safe.
4
+ updated: "2026-10-04"
5
+
6
+ eks:
7
+ source: https://docs.aws.amazon.com/eks/latest/userguide/kubernetes-versions.html
8
+ # After standard support ends, clusters move to extended support automatically and cost more.
9
+ # After extended support ends, AWS upgrades the control plane for you.
10
+ cost: Extended support adds $0.50 per cluster-hour, about $365 a month per cluster.
11
+ monthlyCost: 365
12
+ versions:
13
+ "1.23": { standard: "2023-10-11", extended: "2024-10-11" }
14
+ "1.24": { standard: "2024-01-31", extended: "2025-01-31" }
15
+ "1.25": { standard: "2024-05-01", extended: "2025-05-01" }
16
+ "1.26": { standard: "2024-06-11", extended: "2025-06-11" }
17
+ "1.27": { standard: "2024-07-24", extended: "2025-07-24" }
18
+ "1.28": { standard: "2024-11-26", extended: "2025-11-26" }
19
+ "1.29": { standard: "2025-03-23", extended: "2026-03-23" }
20
+ "1.30": { standard: "2025-07-23", extended: "2026-07-23" }
21
+ "1.31": { standard: "2025-11-26", extended: "2026-11-26" }
22
+ "1.32": { standard: "2026-03-23", extended: "2027-03-23" }
23
+ "1.33": { standard: "2026-07-29", extended: "2027-07-29" }
24
+
25
+ rds:
26
+ source: https://docs.aws.amazon.com/AmazonRDS/latest/UserGuide/extended-support.html
27
+ # After standard support ends, RDS enrols the database in Extended Support and bills it per vCPU-hour.
28
+ cost: RDS Extended Support is billed per vCPU-hour on top of the instance price.
29
+ engines:
30
+ postgres:
31
+ "11": { standard: "2024-02-29" }
32
+ "12": { standard: "2025-02-28" }
33
+ "13": { standard: "2026-02-28" }
34
+ "14": { standard: "2027-02-28" }
35
+ "15": { standard: "2028-02-29" }
36
+ "16": { standard: "2029-02-28" }
37
+ mysql:
38
+ "5.7": { standard: "2024-02-29" }
39
+ "8.0": { standard: "2026-07-31" }
40
+ aurora-postgresql:
41
+ "11": { standard: "2024-02-29" }
42
+ "12": { standard: "2025-02-28" }
43
+ "13": { standard: "2026-02-28" }
44
+ "14": { standard: "2027-02-28" }
45
+ "15": { standard: "2028-02-29" }
46
+ "16": { standard: "2029-02-28" }
47
+ aurora-mysql:
48
+ "5.7": { standard: "2024-10-31" }
49
+
50
+ lambda:
51
+ source: https://docs.aws.amazon.com/lambda/latest/dg/lambda-runtimes.html
52
+ # Deprecated runtimes stop getting security patches; AWS then blocks creating and later updating functions.
53
+ runtimes:
54
+ nodejs14.x: "2023-12-04"
55
+ nodejs16.x: "2024-06-12"
56
+ nodejs18.x: "2025-09-01"
57
+ nodejs20.x: "2026-04-30"
58
+ python3.7: "2023-12-04"
59
+ python3.8: "2024-10-14"
60
+ python3.9: "2025-12-15"
61
+ ruby2.7: "2023-12-07"
62
+ ruby3.2: "2026-03-31"
63
+ java8: "2024-01-08"
64
+ go1.x: "2024-01-08"
65
+ provided: "2024-01-08"
66
+ dotnet6: "2024-12-20"
67
+ dotnet7: "2024-05-14"
68
+ dotnet8: "2026-11-10"
package/src/cli.js ADDED
@@ -0,0 +1,134 @@
1
+ #!/usr/bin/env node
2
+ // retirement-radar scan: read-only. Runs the AWS CLI you already have (your profiles, SSO, roles),
3
+ // calls only list/describe APIs, and prints what is near end of support. Nothing is sent anywhere.
4
+ const { execFile } = require('node:child_process');
5
+ const { SEVERITY, loadRetirements, evaluate, sortFindings, failsAt } = require('./radar');
6
+
7
+ const HELP = `Usage: retirement-radar scan [options]
8
+
9
+ Options:
10
+ --region a,b Regions to scan (default: every enabled region)
11
+ --profile name AWS CLI profile
12
+ --json Print findings as JSON
13
+ --all Also list resources that are fine
14
+ --fail-on level Exit 1 if anything is at this level or worse: ${SEVERITY.slice(0, 4).join(', ')}
15
+ --data file Use your own retirements.yaml
16
+ `;
17
+
18
+ function parseArgs(argv) {
19
+ const o = { cmd: argv[0] };
20
+ for (let i = 1; i < argv.length; i++) {
21
+ const a = argv[i];
22
+ if (a === '--json' || a === '--all') o[a.slice(2)] = true;
23
+ else if (['--region', '--profile', '--fail-on', '--data'].includes(a)) o[a.slice(2)] = argv[++i];
24
+ else throw new Error(`Unknown option ${a}`);
25
+ }
26
+ return o;
27
+ }
28
+
29
+ function awsRunner(profile) {
30
+ return (args, region) => new Promise((resolve, reject) => {
31
+ const full = [...args, '--output', 'json', ...(region ? ['--region', region] : []), ...(profile ? ['--profile', profile] : [])];
32
+ execFile('aws', full, { maxBuffer: 64 * 1024 * 1024, windowsHide: true }, (err, stdout, stderr) => {
33
+ if (err && err.code === 'ENOENT') return reject(new Error('AWS CLI not found. Install AWS CLI v2 and sign in (aws configure / aws sso login).'));
34
+ if (err) return reject(new Error((stderr || err.message).trim().split('\n').pop()));
35
+ try { resolve(JSON.parse(stdout || '{}')); } catch { reject(new Error('Unexpected AWS CLI output')); }
36
+ });
37
+ });
38
+ }
39
+
40
+ const DB_ENGINE = /^(aurora|mysql|postgres|mariadb|oracle|sqlserver)/;
41
+
42
+ async function collectRegion(region, aws, warn) {
43
+ const items = [];
44
+ const step = async (label, fn) => { try { await fn(); } catch (e) { warn(`${region} ${label}: ${e.message}`); } };
45
+ await Promise.all([
46
+ step('EKS', async () => {
47
+ const { clusters = [] } = await aws(['eks', 'list-clusters'], region);
48
+ for (const name of clusters) {
49
+ const { cluster } = await aws(['eks', 'describe-cluster', '--name', name], region);
50
+ items.push({ service: 'EKS', region, resource: name, version: cluster.version });
51
+ }
52
+ }),
53
+ step('RDS', async () => {
54
+ const [{ DBInstances = [] }, { DBClusters = [] }] = await Promise.all([
55
+ aws(['rds', 'describe-db-instances'], region), aws(['rds', 'describe-db-clusters'], region)]);
56
+ // Cluster members share the cluster's engine version: report the cluster once.
57
+ for (const i of DBInstances) if (!i.DBClusterIdentifier && DB_ENGINE.test(i.Engine)) {
58
+ items.push({ service: 'RDS', region, resource: i.DBInstanceIdentifier, engine: i.Engine, version: i.EngineVersion });
59
+ }
60
+ for (const c of DBClusters) if (DB_ENGINE.test(c.Engine)) {
61
+ items.push({ service: 'RDS', region, resource: c.DBClusterIdentifier, engine: c.Engine, version: c.EngineVersion });
62
+ }
63
+ }),
64
+ step('Lambda', async () => {
65
+ const { Functions = [] } = await aws(['lambda', 'list-functions'], region);
66
+ for (const f of Functions) if (f.Runtime) items.push({ service: 'Lambda', region, resource: f.FunctionName, version: f.Runtime });
67
+ }),
68
+ ]);
69
+ return items;
70
+ }
71
+
72
+ async function scan({ regions, aws, db, now = Date.now(), warn = () => {} }) {
73
+ if (!regions || !regions.length) {
74
+ const { Regions = [] } = await aws(['ec2', 'describe-regions'], 'us-east-1');
75
+ regions = Regions.map((r) => r.RegionName);
76
+ }
77
+ const items = (await Promise.all(regions.map((r) => collectRegion(r, aws, warn)))).flat();
78
+ return sortFindings(items.map((i) => evaluate(i, db, now)));
79
+ }
80
+
81
+ // ── Output ─────────────────────────────────────────────────────
82
+ const tty = process.stdout.isTTY && !process.env.NO_COLOR;
83
+ const paint = (code, s) => (tty ? `\x1b[${code}m${s}\x1b[0m` : s);
84
+ const COLOR = { retired: 31, extended: 31, soon: 33, upcoming: 36, ok: 32, unknown: 90 };
85
+
86
+ function table(findings) {
87
+ const rows = findings.map((f) => [f.status.toUpperCase(), f.service, f.region, f.resource,
88
+ f.engine ? `${f.engine} ${f.version}` : f.version, f.date ? `${f.date} (${f.days < 0 ? `${-f.days}d ago` : `in ${f.days}d`})` : '-', f.note]);
89
+ const head = ['STATUS', 'SERVICE', 'REGION', 'RESOURCE', 'VERSION', 'DATE', 'WHAT IT MEANS'];
90
+ const w = head.map((h, c) => Math.max(h.length, ...rows.map((r) => String(r[c]).length)));
91
+ const line = (r, status) => r.map((v, c) => {
92
+ if (c === r.length - 1) return v;
93
+ const cell = String(v).padEnd(w[c]);
94
+ return c === 0 && status ? paint(COLOR[status], cell) : cell;
95
+ }).join(' ');
96
+ return [paint(1, line(head)), ...rows.map((r, i) => line(r, findings[i].status))].join('\n');
97
+ }
98
+
99
+ async function main(argv = process.argv.slice(2)) {
100
+ let o;
101
+ try { o = parseArgs(argv); } catch (e) { console.error(e.message + '\n\n' + HELP); return 2; }
102
+ if (o.cmd !== 'scan') { console.log(HELP); return o.cmd ? 2 : 0; }
103
+ const db = loadRetirements(o.data);
104
+ const warnings = [];
105
+ let findings;
106
+ const aws = awsRunner(o.profile);
107
+ try {
108
+ // Fail fast with one clear message instead of a warning per region.
109
+ const id = await aws(['sts', 'get-caller-identity']);
110
+ if (!o.json) console.log(paint(90, `Scanning AWS account ${id.Account} (read-only)...
111
+ `));
112
+ findings = await scan({ regions: o.region && o.region.split(','), aws, db, warn: (w) => warnings.push(w) });
113
+ } catch (e) { console.error(`retirement-radar: ${e.message}`); return 2; }
114
+
115
+ if (o.json) {
116
+ console.log(JSON.stringify({ dataUpdated: db.updated, findings, warnings }, null, 2));
117
+ } else {
118
+ const shown = o.all ? findings : findings.filter((f) => f.status !== 'ok');
119
+ const count = (s) => findings.filter((f) => f.status === s).length;
120
+ if (shown.length) console.log(table(shown) + '\n');
121
+ const eksExtra = findings.reduce((n, f) => n + (f.monthlyCost || 0), 0);
122
+ const tally = SEVERITY.map((s) => count(s) && `${count(s)} ${s}`).filter(Boolean).join(', ');
123
+ console.log(`${findings.length} resources scanned${tally ? `: ${tally}` : ''}.`);
124
+ if (eksExtra) console.log(paint(31, `EKS extended support is costing about $${eksExtra.toLocaleString()} a month.`));
125
+ if (count('unknown')) console.log(paint(90, 'Unknown = version not in retirements.yaml yet. PRs with a source link welcome.'));
126
+ if (!o.all && count('ok')) console.log(paint(90, `${count('ok')} fine (more than a year left); --all lists them.`));
127
+ console.log(paint(90, `Dates updated ${db.updated}. Sources: ${[db.eks.source, db.rds.source, db.lambda.source].join(' ')}`));
128
+ for (const w of warnings) console.error(paint(33, `warning: ${w}`));
129
+ }
130
+ return o['fail-on'] && failsAt(findings, o['fail-on']) ? 1 : 0;
131
+ }
132
+
133
+ if (require.main === module) main().then((code) => { process.exitCode = code; });
134
+ module.exports = { main, scan, parseArgs, table };
package/src/radar.js ADDED
@@ -0,0 +1,68 @@
1
+ // Match what runs in an AWS account against retirements.yaml. Pure logic; the CLI does the I/O.
2
+ const fs = require('node:fs');
3
+ const path = require('node:path');
4
+ const YAML = require('yaml');
5
+
6
+ const DAY = 86400000;
7
+ const SOON_DAYS = 90;
8
+ const UPCOMING_DAYS = 365;
9
+ // Worst first. "extended" = past standard support and paying for it (EKS, RDS).
10
+ const SEVERITY = ['retired', 'extended', 'soon', 'upcoming', 'ok', 'unknown'];
11
+
12
+ function loadRetirements(file = path.join(__dirname, '..', 'retirements.yaml')) {
13
+ return YAML.parse(fs.readFileSync(file, 'utf8'));
14
+ }
15
+
16
+ // "13.12" -> "13", "9.6.24" -> "9.6", "8.0.35" -> "8.0", "5.7.mysql_aurora.2.11.2" -> "5.7"
17
+ function majorVersion(engine, version) {
18
+ const parts = String(version || '').split('.');
19
+ if (/postgres/.test(engine) && Number(parts[0]) >= 10) return parts[0];
20
+ return parts.slice(0, 2).join('.');
21
+ }
22
+
23
+ const daysUntil = (date, now) => Math.ceil((Date.parse(date) - now) / DAY);
24
+ const byDays = (days) => (days <= SOON_DAYS ? 'soon' : days <= UPCOMING_DAYS ? 'upcoming' : 'ok');
25
+
26
+ // item: { service: 'EKS'|'RDS'|'Lambda', region, resource, version, engine? }
27
+ function evaluate(item, db, now = Date.now()) {
28
+ const f = { ...item, status: 'unknown', date: null, days: null, note: 'Version not in retirements.yaml', source: null };
29
+ if (item.service === 'EKS') {
30
+ const v = db.eks.versions[item.version];
31
+ f.source = db.eks.source;
32
+ if (!v) return f;
33
+ const std = daysUntil(v.standard, now);
34
+ const ext = daysUntil(v.extended, now);
35
+ if (ext < 0) return { ...f, status: 'retired', date: v.extended, days: ext, note: 'Extended support ended; AWS force-upgrades the control plane' };
36
+ if (std < 0) return { ...f, status: 'extended', date: v.extended, days: ext, note: `Paid extended support until this date, then AWS force-upgrades. ${db.eks.cost}`, monthlyCost: db.eks.monthlyCost };
37
+ return { ...f, status: byDays(std), date: v.standard, days: std, note: 'Standard support ends; extended support charges start' };
38
+ }
39
+ if (item.service === 'RDS') {
40
+ const major = majorVersion(item.engine, item.version);
41
+ const v = (db.rds.engines[item.engine] || {})[major];
42
+ f.source = db.rds.source;
43
+ if (!v) return f;
44
+ const std = daysUntil(v.standard, now);
45
+ if (std < 0) return { ...f, status: 'extended', date: v.standard, days: std, note: `Past standard support. ${db.rds.cost}` };
46
+ return { ...f, status: byDays(std), date: v.standard, days: std, note: 'Standard support ends; Extended Support billing starts' };
47
+ }
48
+ if (item.service === 'Lambda') {
49
+ const date = db.lambda.runtimes[item.version];
50
+ f.source = db.lambda.source;
51
+ if (!date) return f;
52
+ const d = daysUntil(date, now);
53
+ if (d < 0) return { ...f, status: 'retired', date, days: d, note: 'Runtime deprecated: no security patches' };
54
+ return { ...f, status: byDays(d), date, days: d, note: 'Runtime deprecation' };
55
+ }
56
+ return f;
57
+ }
58
+
59
+ const sortFindings = (list) => [...list].sort((a, b) =>
60
+ SEVERITY.indexOf(a.status) - SEVERITY.indexOf(b.status) || (a.days ?? 1e9) - (b.days ?? 1e9));
61
+
62
+ // Exit non-zero in CI when anything is at or worse than `level`.
63
+ const failsAt = (findings, level) => {
64
+ const max = SEVERITY.indexOf(level);
65
+ return max >= 0 && findings.some((f) => f.status !== 'unknown' && SEVERITY.indexOf(f.status) <= max);
66
+ };
67
+
68
+ module.exports = { SEVERITY, loadRetirements, majorVersion, evaluate, sortFindings, failsAt };