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

Rudimentary implementation of "uptime" filter #398

Open
wants to merge 8 commits into
base: develop
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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" "<x" or "x-y" (in hours). Only valid for running EC2 (else true)|
| and | `Filter[]` | - | Matches when _all_ the filters within it match |
| or | `Filter[]` | - | Matches when _any_ of the filters within match |
| not | `Filter` | - | Matches when the filter within _doesn't_ match |
Expand Down
1 change: 1 addition & 0 deletions lib/config-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ const BaseFilters = z
to: z.string().optional(),
})
.optional(),
uptime: z.union([z.array(z.string()), z.string()]).optional(),
})
.strict();

Expand Down
55 changes: 55 additions & 0 deletions plugins/filters/uptime.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import { ToolingInterface } from '../../drivers/instrumentedResource.js';
import dateTime from '../../lib/dateTime.js';
import { arrayToOr, Filter, FilterCtor } from './index.js';

export default class FilterUptime implements Filter, FilterCtor {
static readonly FILTER_NAME = 'uptime';
private minValue: number | undefined = undefined;
private maxValue: number | undefined = undefined;
private readonly isReady: Promise<Filter>;

ready(): Promise<Filter> {
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 '<x' and '>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 = /([\d.]+)-([\d.]+)/.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 true; // only consider running resources
}
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;
}
}
101 changes: 100 additions & 1 deletion test/filters/filters.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,10 @@
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);

Expand All @@ -18,6 +19,14 @@
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();
}

Expand Down Expand Up @@ -61,6 +70,7 @@
AvailabilityZone: 'ap-southeast-2c',
Tenancy: 'default',
},
LaunchTime: '2024-02-19T19:50Z', // Test is frozen at 2024-02-19T21:56Z so uptime=2.1h
},
};

Expand Down Expand Up @@ -200,6 +210,18 @@
},
],
},
{
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: '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 },
],
},
{
name: 'and',
tests: [
Expand Down Expand Up @@ -494,3 +516,80 @@
});
}
});

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, // schedule is sufficient to meet uptime requirements

Check warning on line 526 in test/filters/filters.spec.ts

View workflow job for this annotation

GitHub Actions / Code style

Delete `·`
},
{
name: 'require 1 hour',
schedule: 'Start=09:00;Stop=10:00',
filter: [{ type: 'ec2' }, { 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, // 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

Check warning on line 544 in test/filters/filters.spec.ts

View workflow job for this annotation

GitHub Actions / Code style

Delete `·`
},
];

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);
});
}
});
1 change: 1 addition & 0 deletions test/plugins/powercycleCentral.config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down