From ae2d31bed44735b3e48d48995a19c1ec47b782d0 Mon Sep 17 00:00:00 2001 From: "Roberts, Simon" Date: Sat, 20 Jul 2024 10:58:41 +1000 Subject: [PATCH 1/8] Document uptime in README. Only match running resources. --- README.md | 1 + plugins/filters/uptime.ts | 56 ++++++++++++++++++++++++++++++++++++ test/filters/filters.spec.ts | 21 ++++++++++++++ 3 files changed, 78 insertions(+) create mode 100644 plugins/filters/uptime.ts diff --git a/README.md b/README.md index 5a196402..cbb5e67e 100644 --- a/README.md +++ b/README.md @@ -403,6 +403,7 @@ Filters can be specified as objects or as string arrays, utilizing the [shorthan | accountId | `string` | `accountId: '123456789012'` | Matches resource accountId | | resource | `object { path string, [option]: string }` | `{ path: 'jmes.path', equals: 'pathValue' }` | Matches extra resource properties, specific to the resource type.`path` is a [jmespath](https://jmespath.org/ ), | | matchWindow | `object { from string, to: string }` | `{ from: '2024-02-15', to: '2024-02-20' }` | Matches the current date/time (in ISO 8601 format). Both from/to are optional. Watch out for timezones! | +| uptime | `string` | `uptime: '> 1.5'` | Matches when the uptime for the resource is ">x" "; + + ready(): Promise { + return this.isReady; + } + + constructor(config: any) { + this.isReady = new Promise((resolve) => { + if (Array.isArray(config)) { + resolve(arrayToOr(FilterUptime.FILTER_NAME, config)); + } else { + // Simple parser for 'nnn' and 'between nnn and nnn' strings + const s = config.trim().toLowerCase(); + if (s.startsWith('<')) { + this.maxValue = parseFloat(s.substring(1)); + } else if (s.startsWith('>')) { + this.minValue = parseFloat(s.substring(1)); + } else { + const regexp = /between ([\d.]+) and ([\d.]+)/; + const matches = regexp.exec(s); + if (matches) { + this.minValue = parseFloat(matches[1]); + this.maxValue = parseFloat(matches[2]); + } + } + // default undefined/undefined doesn't match anything + resolve(this); + } + }); + } + + matches(resource: ToolingInterface): boolean { + if (resource.resourceState !== 'running') { + return false; + } + const uptime = dateTime.calculateUptime(resource.launchTimeUtc); + if (this.minValue !== undefined && uptime < this.minValue) { + return false; + } + if (this.maxValue !== undefined && uptime > this.maxValue) { + return false; + } + if (this.minValue === undefined && this.maxValue === undefined) { + return false; + } + return true; + } +} diff --git a/test/filters/filters.spec.ts b/test/filters/filters.spec.ts index 1856f570..87572aae 100644 --- a/test/filters/filters.spec.ts +++ b/test/filters/filters.spec.ts @@ -18,6 +18,14 @@ class TestingResource extends ToolingInterface { if (this.topResource['tags'] === undefined) this.topResource['tags'] = {}; } get launchTimeUtc(): DateTime { + if (this.resourceType == 'ec2' && this.resource.LaunchTime !== undefined) { + // EC2 - If a resource is stopped, this still contains the original launch time + return dateTime.getUtcDateTime(this.resource.LaunchTime); + } else if (this.resourceType == 'rdsInstance' && this.resource.LaunchTime !== undefined) { + // RDS - Not actually "uptime" + return dateTime.getUtcDateTime(this.resource.ClusterCreateTime); + } + // We couldn't determine a real launchTime, so we'll just use now return DateTime.now(); } @@ -61,6 +69,7 @@ const basicEc2 = { AvailabilityZone: 'ap-southeast-2c', Tenancy: 'default', }, + LaunchTime: '2024-02-19T19:50Z', // Test is frozen at 2024-02-19T21:56Z so uptime=2.1h }, }; @@ -200,6 +209,18 @@ const filterTests = [ }, ], }, + { + name: 'uptime', + tests: [ + { name: 'match gt yes', filter: { uptime: '> 1.5' }, resource: basicEc2, matches: true }, + { name: 'match gt no', filter: { uptime: '> 5' }, resource: basicEc2, matches: false }, + { name: 'match lt yes', filter: { uptime: '< 5' }, resource: basicEc2, matches: true }, + { name: 'match lt no', filter: { uptime: '< 1' }, resource: basicEc2, matches: false }, + { name: 'match between yes', filter: { uptime: 'between 1 and 5' }, resource: basicEc2, matches: true }, + { name: 'match between no1', filter: { uptime: 'between 0 and 1' }, resource: basicEc2, matches: false }, + { name: 'match between no1', filter: { uptime: 'between 5 and 10' }, resource: basicEc2, matches: false }, + ], + }, { name: 'and', tests: [ From 932e67389a208e5dc3c48dde77f629af4ad937b9 Mon Sep 17 00:00:00 2001 From: "Roberts, Simon" Date: Sat, 20 Jul 2024 11:04:04 +1000 Subject: [PATCH 2/8] Add support for "uptime: x-y" format in uptime --- plugins/filters/uptime.ts | 9 +++++++-- test/filters/filters.spec.ts | 5 ++++- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/plugins/filters/uptime.ts b/plugins/filters/uptime.ts index d5c04992..699724dd 100644 --- a/plugins/filters/uptime.ts +++ b/plugins/filters/uptime.ts @@ -24,11 +24,16 @@ export default class FilterUptime implements Filter, FilterCtor { } else if (s.startsWith('>')) { this.minValue = parseFloat(s.substring(1)); } else { - const regexp = /between ([\d.]+) and ([\d.]+)/; - const matches = regexp.exec(s); + const matches = /between ([\d.]+) and ([\d.]+)/.exec(s); if (matches) { this.minValue = parseFloat(matches[1]); this.maxValue = parseFloat(matches[2]); + } else { + const matches = /([\d.]+)-([\d.]+)/.exec(s); + if (matches) { + this.minValue = parseFloat(matches[1]); + this.maxValue = parseFloat(matches[2]); + } } } // default undefined/undefined doesn't match anything diff --git a/test/filters/filters.spec.ts b/test/filters/filters.spec.ts index 87572aae..ad916b37 100644 --- a/test/filters/filters.spec.ts +++ b/test/filters/filters.spec.ts @@ -218,7 +218,10 @@ const filterTests = [ { name: 'match lt no', filter: { uptime: '< 1' }, resource: basicEc2, matches: false }, { name: 'match between yes', filter: { uptime: 'between 1 and 5' }, resource: basicEc2, matches: true }, { name: 'match between no1', filter: { uptime: 'between 0 and 1' }, resource: basicEc2, matches: false }, - { name: 'match between no1', filter: { uptime: 'between 5 and 10' }, resource: basicEc2, matches: false }, + { name: 'match between no2', filter: { uptime: 'between 5 and 10' }, resource: basicEc2, matches: false }, + { name: 'match dash yes', filter: { uptime: '1-5' }, resource: basicEc2, matches: true }, + { name: 'match dash no1', filter: { uptime: '0-1' }, resource: basicEc2, matches: false }, + { name: 'match dash no2', filter: { uptime: '5-10' }, resource: basicEc2, matches: false }, ], }, { From 5053d1fb572a066d38e6003f22281f6e9c6f73a2 Mon Sep 17 00:00:00 2001 From: "Roberts, Simon" Date: Sat, 20 Jul 2024 13:03:11 +1000 Subject: [PATCH 3/8] lint --- plugins/filters/uptime.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/filters/uptime.ts b/plugins/filters/uptime.ts index 699724dd..18dc11b1 100644 --- a/plugins/filters/uptime.ts +++ b/plugins/filters/uptime.ts @@ -1,6 +1,6 @@ import { ToolingInterface } from '../../drivers/instrumentedResource.js'; import dateTime from '../../lib/dateTime.js'; -import { arrayToOr, Filter, FilterCtor, StringCompareOptions } from './index.js'; +import { arrayToOr, Filter, FilterCtor } from './index.js'; export default class FilterUptime implements Filter, FilterCtor { static readonly FILTER_NAME = 'uptime'; From 1d0d859906b8e44e26a55db88bb1b441f5435ed5 Mon Sep 17 00:00:00 2001 From: "Roberts, Simon" Date: Sat, 20 Jul 2024 13:08:21 +1000 Subject: [PATCH 4/8] Use shorthand x-y when describing "uptime between" filter --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index cbb5e67e..c0ead3a6 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ Filters can be specified as objects or as string arrays, utilizing the [shorthan | accountId | `string` | `accountId: '123456789012'` | Matches resource accountId | | resource | `object { path string, [option]: string }` | `{ path: 'jmes.path', equals: 'pathValue' }` | Matches extra resource properties, specific to the resource type.`path` is a [jmespath](https://jmespath.org/ ), | | matchWindow | `object { from string, to: string }` | `{ from: '2024-02-15', to: '2024-02-20' }` | Matches the current date/time (in ISO 8601 format). Both from/to are optional. Watch out for timezones! | -| uptime | `string` | `uptime: '> 1.5'` | Matches when the uptime for the resource is ">x" " 1.5'` | Matches when the uptime for the resource is ">x" " Date: Sat, 20 Jul 2024 14:07:17 +1000 Subject: [PATCH 5/8] Add uptime to schedule --- lib/config-schema.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/lib/config-schema.ts b/lib/config-schema.ts index 8d0c3c6e..3999bf79 100644 --- a/lib/config-schema.ts +++ b/lib/config-schema.ts @@ -52,6 +52,7 @@ const BaseFilters = z to: z.string().optional(), }) .optional(), + uptime: z.union([z.array(z.string()), z.string()]).optional(), }) .strict(); From a74a0a9c5451d21f2d8a4c46c54d77754b549a65 Mon Sep 17 00:00:00 2001 From: "Roberts, Simon" Date: Sun, 21 Jul 2024 09:53:33 +1000 Subject: [PATCH 6/8] Remove support for longhand "between" in timezone. Make condition return True for non-running resources. --- README.md | 2 +- plugins/filters/uptime.ts | 12 +++--------- test/filters/filters.spec.ts | 9 +++------ test/plugins/powercycleCentral.config.yaml | 1 + 4 files changed, 8 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c0ead3a6..89523ba8 100644 --- a/README.md +++ b/README.md @@ -403,7 +403,7 @@ Filters can be specified as objects or as string arrays, utilizing the [shorthan | accountId | `string` | `accountId: '123456789012'` | Matches resource accountId | | resource | `object { path string, [option]: string }` | `{ path: 'jmes.path', equals: 'pathValue' }` | Matches extra resource properties, specific to the resource type.`path` is a [jmespath](https://jmespath.org/ ), | | matchWindow | `object { from string, to: string }` | `{ from: '2024-02-15', to: '2024-02-20' }` | Matches the current date/time (in ISO 8601 format). Both from/to are optional. Watch out for timezones! | -| uptime | `string` | `uptime: '> 1.5'` | Matches when the uptime for the resource is ">x" " 1.5'` | Matches when the uptime for the resource is ">x" "nnn' and 'between nnn and nnn' strings + // Simple parser for 'y' and 'x-y' strings const s = config.trim().toLowerCase(); if (s.startsWith('<')) { this.maxValue = parseFloat(s.substring(1)); } else if (s.startsWith('>')) { this.minValue = parseFloat(s.substring(1)); } else { - const matches = /between ([\d.]+) and ([\d.]+)/.exec(s); + const matches = /([\d.]+)-([\d.]+)/.exec(s); if (matches) { this.minValue = parseFloat(matches[1]); this.maxValue = parseFloat(matches[2]); - } else { - const matches = /([\d.]+)-([\d.]+)/.exec(s); - if (matches) { - this.minValue = parseFloat(matches[1]); - this.maxValue = parseFloat(matches[2]); - } } } // default undefined/undefined doesn't match anything @@ -44,7 +38,7 @@ export default class FilterUptime implements Filter, FilterCtor { matches(resource: ToolingInterface): boolean { if (resource.resourceState !== 'running') { - return false; + return true; // only consider running resources } const uptime = dateTime.calculateUptime(resource.launchTimeUtc); if (this.minValue !== undefined && uptime < this.minValue) { diff --git a/test/filters/filters.spec.ts b/test/filters/filters.spec.ts index ad916b37..a75aa8cf 100644 --- a/test/filters/filters.spec.ts +++ b/test/filters/filters.spec.ts @@ -216,12 +216,9 @@ const filterTests = [ { name: 'match gt no', filter: { uptime: '> 5' }, resource: basicEc2, matches: false }, { name: 'match lt yes', filter: { uptime: '< 5' }, resource: basicEc2, matches: true }, { name: 'match lt no', filter: { uptime: '< 1' }, resource: basicEc2, matches: false }, - { name: 'match between yes', filter: { uptime: 'between 1 and 5' }, resource: basicEc2, matches: true }, - { name: 'match between no1', filter: { uptime: 'between 0 and 1' }, resource: basicEc2, matches: false }, - { name: 'match between no2', filter: { uptime: 'between 5 and 10' }, resource: basicEc2, matches: false }, - { name: 'match dash yes', filter: { uptime: '1-5' }, resource: basicEc2, matches: true }, - { name: 'match dash no1', filter: { uptime: '0-1' }, resource: basicEc2, matches: false }, - { name: 'match dash no2', filter: { uptime: '5-10' }, resource: basicEc2, matches: false }, + { name: 'match between yes', filter: { uptime: '1-5' }, resource: basicEc2, matches: true }, + { name: 'match between no1', filter: { uptime: '0-1' }, resource: basicEc2, matches: false }, + { name: 'match between no2', filter: { uptime: '5-10' }, resource: basicEc2, matches: false }, ], }, { diff --git a/test/plugins/powercycleCentral.config.yaml b/test/plugins/powercycleCentral.config.yaml index 8055ea84..661029c4 100644 --- a/test/plugins/powercycleCentral.config.yaml +++ b/test/plugins/powercycleCentral.config.yaml @@ -84,6 +84,7 @@ defaults: filter: - name: 'junk-asg-3-day' - type: ec2 + - uptime: '>200' schedule: 'Start=11:00|mon-fri;Stop=13:00|mon-fri' priority: 10 # RDS From 718a8df3759c02a08720cfd05501494f794404a3 Mon Sep 17 00:00:00 2001 From: "Roberts, Simon" Date: Mon, 22 Jul 2024 10:53:50 +1000 Subject: [PATCH 7/8] Add tests to validate "uptime>5" type functionality --- test/filters/filters.spec.ts | 74 +++++++++++++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/test/filters/filters.spec.ts b/test/filters/filters.spec.ts index a75aa8cf..9726d8d0 100644 --- a/test/filters/filters.spec.ts +++ b/test/filters/filters.spec.ts @@ -3,9 +3,10 @@ import chaiAsPromised from 'chai-as-promised'; import { expect } from 'chai'; import { buildFilter } from '../../plugins/filters/index.js'; import { ToolingInterface } from '../../drivers/instrumentedResource.js'; -import { DateTime } from 'luxon'; +import { DateTime, Interval } from 'luxon'; import { makeResourceTags } from '../../lib/common.js'; import dateTime from '../../lib/dateTime.js'; +import getParser from '../../plugins/parsers/index.js'; chai.use(chaiAsPromised); @@ -515,3 +516,74 @@ describe('filter', function () { }); } }); + +const uptimeTests = [ + { name: 'no require 5 hour', schedule: 'Start=09:00;Stop=10:00', filter: { type: 'ec2' }, uptime: 1 }, + { + name: 'require 0.5 hour', + schedule: 'Start=09:00;Stop=10:00', + filter: [{ type: 'ec2' }, { uptime: '> 0.5' }], + uptime: 1, + }, + { + name: 'require 1 hour', + schedule: 'Start=09:00;Stop=10:00', + filter: [{ type: 'ec2' }, { uptime: '> 1' }], + uptime: 1, + }, + { + name: 'require 5 hour', + schedule: 'Start=09:00;Stop=10:00', + filter: [{ type: 'ec2' }, { uptime: '> 5' }], + uptime: 5, + }, +]; + +describe('Filter with uptime stops correctly', function () { + // Test that a filter with "uptime" delays stopping newly started resources + const testInterval = 15; // number of minutes between samples + const timeNow = DateTime.fromISO('2024-02-19T00:00Z').toUTC(); + const interval = Interval.fromDateTimes(timeNow, timeNow.plus({ days: 1 })); + const startTimes = interval.splitBy({ minutes: testInterval }).map((d) => d.start); + + for (const uptimeTest of uptimeTests) { + it(uptimeTest.name, async function () { + // Start every test at midnight with stopped resources + const resource = { ...basicEc2, LaunchTime: undefined }; + resource.resourceState = 'stopped'; + + const strictParser = await getParser('strict'); + const filter = await buildFilter(uptimeTest.filter); + + // For every time-slot in the window, check if the filter matches, start/stop, and count uptime + let uptime = 0; // in hours + for (const currentTime of startTimes) { + dateTime.freezeTime(currentTime!.toISO()); + const matches = filter.matches(new TestingResource(resource)); + if (matches) { + // if the filter matches, then "apply" the action for next cycle + const [action] = strictParser(uptimeTest.schedule, currentTime); + if (action == 'START') { + if (resource.resourceState == 'stopped') { + resource.resourceState = 'running'; + if (currentTime !== null) { + resource.resource.LaunchTime = currentTime.toISO(); + // logger.error(`Now ${currentTime} starting`); + } + } + } else if (action == 'STOP') { + // if (resource.resourceState == 'running') { + // logger.error(`Now ${currentTime} stopping`); + // } + resource.resourceState = 'stopped'; + } + } + // logger.error(`Now ${currentTime} ==> ${matches} (currently ${resource.resourceState})`); + if (resource.resourceState == 'running') { + uptime += testInterval / 60; + } + } + expect(uptime, 'Calculated uptime matches predicted').to.be.equal(uptimeTest.uptime); + }); + } +}); From ff209da5e572f1b7bcf4400e53830a61a90c3704 Mon Sep 17 00:00:00 2001 From: "Roberts, Simon" Date: Mon, 22 Jul 2024 11:09:46 +1000 Subject: [PATCH 8/8] Add test case for uptime range --- test/filters/filters.spec.ts | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/test/filters/filters.spec.ts b/test/filters/filters.spec.ts index 9726d8d0..d8e4be09 100644 --- a/test/filters/filters.spec.ts +++ b/test/filters/filters.spec.ts @@ -523,19 +523,25 @@ const uptimeTests = [ name: 'require 0.5 hour', schedule: 'Start=09:00;Stop=10:00', filter: [{ type: 'ec2' }, { uptime: '> 0.5' }], - uptime: 1, + uptime: 1, // schedule is sufficient to meet uptime requirements }, { name: 'require 1 hour', schedule: 'Start=09:00;Stop=10:00', filter: [{ type: 'ec2' }, { uptime: '> 1' }], - uptime: 1, + uptime: 1, // schedule is sufficient to meet uptime requirements }, { name: 'require 5 hour', schedule: 'Start=09:00;Stop=10:00', filter: [{ type: 'ec2' }, { uptime: '> 5' }], - uptime: 5, + uptime: 5, // cannot stop until 5 hours have elapsed since start + }, + { + name: 'require 2-3 hour', + schedule: 'Start=09:00;Stop=10:00', + filter: [{ type: 'ec2' }, { uptime: '2-3' }], + uptime: 2, // cannot stop until at least 2 hours have elapsed since start }, ];