From 5b032c4a13fdcef4b3853b826c976fc54b64b139 Mon Sep 17 00:00:00 2001 From: gotodeploy <1491134+gotodeploy@users.noreply.github.com> Date: Sat, 27 Feb 2021 19:05:10 +0900 Subject: [PATCH] feat: Support scaleIn and scaleOut scheduling --- API.md | 64 ++++++++ README.md | 26 ++-- src/integ.valheim.ts | 45 ++---- src/valheim.ts | 188 ++++++++++++++++++++++-- test/__snapshots__/valheim.test.ts.snap | 1 - test/valheim.test.ts | 58 +++++++- 6 files changed, 328 insertions(+), 54 deletions(-) diff --git a/API.md b/API.md index 8c8cff3..dcdf994 100644 --- a/API.md +++ b/API.md @@ -5,6 +5,7 @@ Name|Description ----|----------- [ValheimWorld](#cdk-valheim-valheimworld)|*No description* +[ValheimWorldScalingSchedule](#cdk-valheim-valheimworldscalingschedule)|Represents the schedule to determine when the server starts or terminates. **Structs** @@ -12,6 +13,7 @@ Name|Description Name|Description ----|----------- [ValheimWorldProps](#cdk-valheim-valheimworldprops)|*No description* +[ValheimWorldScalingScheduleProps](#cdk-valheim-valheimworldscalingscheduleprops)|Options for ValheimWorldScalingSchedule. @@ -42,6 +44,7 @@ new ValheimWorld(scope: Construct, id: string, props?: ValheimWorldProps) * **image** ([ContainerImage](#aws-cdk-aws-ecs-containerimage)) *No description* __*Optional*__ * **logGroup** ([LogDriver](#aws-cdk-aws-ecs-logdriver)) Valheim Server log Group. __*Default*__: Create the new AWS Cloudwatch Log Group for Valheim Server. * **memoryLimitMiB** (number) The amount (in MiB) of memory used by the task. __*Default*__: 2048 + * **schedules** (Array<[ValheimWorldScalingScheduleProps](#cdk-valheim-valheimworldscalingscheduleprops)>) Running schedules. __*Default*__: Always running. * **vpc** ([IVpc](#aws-cdk-aws-ec2-ivpc)) The VPC where your ECS instances will be running or your ENIs will be deployed. __*Default*__: creates a new VPC with two AZs @@ -54,6 +57,52 @@ Name | Type | Description **backupPlan** | [BackupPlan](#aws-cdk-aws-backup-backupplan) | **fileSystem** | [FileSystem](#aws-cdk-aws-efs-filesystem) | **service** | [FargateService](#aws-cdk-aws-ecs-fargateservice) | +**schedules**? | Array<[ValheimWorldScalingSchedule](#cdk-valheim-valheimworldscalingschedule)> | __*Optional*__ + + + +## class ValheimWorldScalingSchedule + +Represents the schedule to determine when the server starts or terminates. + + +### Initializer + + + + +```ts +new ValheimWorldScalingSchedule(schedule: ValheimWorldScalingScheduleProps) +``` + +* **schedule** ([ValheimWorldScalingScheduleProps](#cdk-valheim-valheimworldscalingscheduleprops)) *No description* + * **startAt** ([CronOptions](#aws-cdk-aws-applicationautoscaling-cronoptions)) Options to configure a cron expression for server for server launching schedule. + * **stopAt** ([CronOptions](#aws-cdk-aws-applicationautoscaling-cronoptions)) Options to configure a cron expression for server zero-scale schedule. + + + +### Properties + + +Name | Type | Description +-----|------|------------- +**startAt** | [CronOptions](#aws-cdk-aws-applicationautoscaling-cronoptions) | Options to configure a cron expression for server for server launching schedule. +**stopAt** | [CronOptions](#aws-cdk-aws-applicationautoscaling-cronoptions) | Options to configure a cron expression for server zero-scale schedule. + +### Methods + + +#### toCronOptions() + +Returns the cron option merged both startAt and endAt. + +```ts +toCronOptions(): CronOptions +``` + + +__Returns__: +* [CronOptions](#aws-cdk-aws-applicationautoscaling-cronoptions) @@ -74,7 +123,22 @@ Name | Type | Description **image**? | [ContainerImage](#aws-cdk-aws-ecs-containerimage) | __*Optional*__ **logGroup**? | [LogDriver](#aws-cdk-aws-ecs-logdriver) | Valheim Server log Group.
__*Default*__: Create the new AWS Cloudwatch Log Group for Valheim Server. **memoryLimitMiB**? | number | The amount (in MiB) of memory used by the task.
__*Default*__: 2048 +**schedules**? | Array<[ValheimWorldScalingScheduleProps](#cdk-valheim-valheimworldscalingscheduleprops)> | Running schedules.
__*Default*__: Always running. **vpc**? | [IVpc](#aws-cdk-aws-ec2-ivpc) | The VPC where your ECS instances will be running or your ENIs will be deployed.
__*Default*__: creates a new VPC with two AZs +## struct ValheimWorldScalingScheduleProps + + +Options for ValheimWorldScalingSchedule. + + + +Name | Type | Description +-----|------|------------- +**startAt** | [CronOptions](#aws-cdk-aws-applicationautoscaling-cronoptions) | Options to configure a cron expression for server for server launching schedule. +**stopAt** | [CronOptions](#aws-cdk-aws-applicationautoscaling-cronoptions) | Options to configure a cron expression for server zero-scale schedule. + + + diff --git a/README.md b/README.md index 9f70e6e..f41514f 100644 --- a/README.md +++ b/README.md @@ -12,20 +12,26 @@ See [API.md](API.md) new ValheimWorld(stack, 'ValheimWorld', { cpu: 2048, memoryLimitMiB: 4096, + schedules: [{ + startAt: { hour: '12', weekDay: '0-3' }, + stopAt: { hour: '1', weekDay: '0-3' }, + }], environment: { SERVER_NAME: 'CDK Valheim', WORLD_NAME: 'Amazon', SERVER_PASS: 'fargate', - // SERVER_PUBLIC: 1, - // UPDATE_INTERVAL: 900, - // BACKUPS_INTERVAL: 3600, + BACKUPS: 'false', + // SERVER_PORT: '2456', + // SERVER_PUBLIC: 'true', + // UPDATE_CRON: '*/15 * * * *', + // RESTART_CRON: '0 5 * * *', + // TZ: 'Etc/UTC', + // BACKUPS_CRON: '0 * * * *', // BACKUPS_DIRECTORY: '/config/backups', - // BACKUPS_MAX_AGE: 3, - // BACKUPS_DIRECTORY_PERMISSIONS: 755, - // BACKUPS_FILE_PERMISSIONS: 644, - // CONFIG_DIRECTORY_PERMISSIONS: 755, - // WORLDS_DIRECTORY_PERMISSIONS: 755, - // WORLDS_FILE_PERMISSIONS: 644, + // BACKUPS_MAX_AGE: '3', + // PERMISSIONS_UMASK: '022', + // STEAMCMD_ARGS: 'validate', + // VALHEIM_PLUS: 'false', }, }); ``` @@ -35,7 +41,7 @@ new ValheimWorld(stack, 'ValheimWorld', { * Snapshot ```sh -yarn test +npx projen test ``` * Integration diff --git a/src/integ.valheim.ts b/src/integ.valheim.ts index 8ead3ac..0780355 100644 --- a/src/integ.valheim.ts +++ b/src/integ.valheim.ts @@ -1,4 +1,3 @@ -import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; import { App, Stack } from '@aws-cdk/core'; import { ValheimWorld } from './index'; @@ -10,37 +9,25 @@ const env = { const app = new App(); const stack = new Stack(app, 'ValheimStack', { env }); -const valheimWorld = new ValheimWorld(stack, 'ValheimWorld', { +new ValheimWorld(stack, 'ValheimWorld', { + // Warning: It's UTC. + schedules: [{ + startAt: { hour: '12', weekDay: '0-3' }, + stopAt: { hour: '1', weekDay: '0-3' }, + }, + // It's friday night ;) + { + startAt: { hour: '12', weekDay: '4' }, + stopAt: { hour: '4', weekDay: '4' }, + }, + // It's weekend. + { + startAt: { weekDay: '5' }, + stopAt: { weekDay: '0' }, + }], environment: { SERVER_NAME: 'CDK Valheim', WORLD_NAME: 'Amazon', SERVER_PASS: 'fargate', - // SERVER_PUBLIC: 1, - // UPDATE_INTERVAL: 900, - // BACKUPS_INTERVAL: 3600, - // BACKUPS_DIRECTORY: '/config/backups', - // BACKUPS_MAX_AGE: 3, - // BACKUPS_DIRECTORY_PERMISSIONS: 755, - // BACKUPS_FILE_PERMISSIONS: 644, - // CONFIG_DIRECTORY_PERMISSIONS: 755, - // WORLDS_DIRECTORY_PERMISSIONS: 755, - // WORLDS_FILE_PERMISSIONS: 644, }, }); - -const taskCount = valheimWorld.service.autoScaleTaskCount({ - maxCapacity: 1, -}); - -// Warning: It's UTC -taskCount.scaleOnSchedule('StopAtMidnigt', { - schedule: appscaling.Schedule.cron({ hour: '0' }), - minCapacity: 0, - maxCapacity: 0, -}); - -taskCount.scaleOnSchedule('StartAtMorning', { - schedule: appscaling.Schedule.cron({ hour: '9' }), - minCapacity: 1, - maxCapacity: 1, -}); diff --git a/src/valheim.ts b/src/valheim.ts index acd1f82..4cc921f 100644 --- a/src/valheim.ts +++ b/src/valheim.ts @@ -1,3 +1,4 @@ +import * as appscaling from '@aws-cdk/aws-applicationautoscaling'; import * as backup from '@aws-cdk/aws-backup'; import * as ec2 from '@aws-cdk/aws-ec2'; import * as ecs from '@aws-cdk/aws-ecs'; @@ -6,6 +7,29 @@ import * as events from '@aws-cdk/aws-events'; import * as logs from '@aws-cdk/aws-logs'; import * as cdk from '@aws-cdk/core'; +/** + * Options for ValheimWorldScalingSchedule. + */ +export interface ValheimWorldScalingScheduleProps { + /** + * Options to configure a cron expression for server for server launching schedule. + * + * All fields are strings so you can use complex expressions. Absence of + * a field implies '*' or '?', whichever one is appropriate. Only comma + * separated numbers and hypens are allowed. + */ + readonly startAt: appscaling.CronOptions; + + /** + * Options to configure a cron expression for server zero-scale schedule. + * + * All fields are strings so you can use complex expressions. Absence of + * a field implies '*' or '?', whichever one is appropriate. Only comma + * separated numbers and hypens are allowed. + */ + readonly stopAt: appscaling.CronOptions; +} + export interface ValheimWorldProps { /** * The VPC where your ECS instances will be running or your ENIs will be deployed. @@ -78,19 +102,137 @@ export interface ValheimWorldProps { readonly environment?: { [key: string]: string; }; + /** * Valheim Server log Group. * * @default - Create the new AWS Cloudwatch Log Group for Valheim Server. */ readonly logGroup?: ecs.LogDriver; + + /** + * Running schedules. + * + * @default - Always running. + */ + readonly schedules?: ValheimWorldScalingScheduleProps[]; } +/** + * Represents the schedule to determine when the server starts or terminates. + */ +export class ValheimWorldScalingSchedule { + private static range(start: number, stop: number) { + return Array.from( + { length: (stop - start) + 1 }, (_, i) => start + i); + } + + private static toIntArraySorted(expression: string, allowedRange: number[]): Uint8Array { + const flat = (nested: any[]) => [].concat(...nested); + + return Uint8Array.from( + flat( + expression + .split(',') + .map(chunk => { + const rangeChunk = chunk.split('-'); + if (rangeChunk.length == 2) { + return allowedRange.slice( + parseInt(rangeChunk[0], 10), + parseInt(rangeChunk[1], 10) + 1, + ); + } + return parseInt(chunk, 10); + }), + ), + ).sort(); + } + + /** + * Options to configure a cron expression for server for server launching schedule. + * + * All fields are strings so you can use complex expressions. Absence of + * a field implies '*' or '?', whichever one is appropriate. Only comma + * separated numbers and hypens are allowed. + */ + public readonly startAt: appscaling.CronOptions; + + /** + * Options to configure a cron expression for server zero-scale schedule. + * + * All fields are strings so you can use complex expressions. Absence of + * a field implies '*' or '?', whichever one is appropriate. Only comma + * separated numbers and hypens are allowed. + */ + public readonly stopAt: appscaling.CronOptions; + + constructor(schedule: ValheimWorldScalingScheduleProps) { + this.startAt = schedule.startAt; + this.stopAt = schedule.stopAt; + } + + private toCron(propertyName: string, maxRange: number, start?: string, stop?: string): string | undefined { + if (typeof start === 'undefined' && typeof stop === 'undefined') { + return undefined; + } + + if (typeof start === 'undefined' || typeof stop === 'undefined') { + throw new Error(`The property "${propertyName}" must be set for both startAt and endAt.`); + } + + const regex = new RegExp(/^$|[^\d\-,]+/); + if (regex.test(start) || regex.test(stop)) { + throw new Error(`The property "${propertyName}" is only allowed to use numbers, hypens and commas.`); + } + + const allowedRange = ValheimWorldScalingSchedule.range(0, maxRange); + let from = ValheimWorldScalingSchedule.toIntArraySorted(start, allowedRange); + let to = ValheimWorldScalingSchedule.toIntArraySorted(stop, allowedRange); + + if (from.length != to.length) { + throw new Error('The lengths of both startAt and endAt properties must be exactly the same.'); + } + + if (from[0] > to[0]) { + return `${from[0]}-${maxRange},0-${to[0]}`; + } + + let cronExpression: string[] = []; + from.forEach((n, i) => { + if (n == to[i]) { + cronExpression.push(n.toString()); + } else { + cronExpression.push(`${n}-${to[i]}`); + } + }); + + return [...new Set([...cronExpression])].join(','); + } + + private toCronHour(): string | undefined { + return this.toCron('hour', 23, this.startAt.hour, this.stopAt.hour); + } + + private toCronWeekDay(): string | undefined { + return this.toCron('weekDay', 6, this.startAt.weekDay, this.stopAt.weekDay); + } + + /** + * Returns the cron option merged both startAt and endAt. + */ + public toCronOptions(): appscaling.CronOptions { + return { + hour: this.toCronHour(), + weekDay: this.toCronWeekDay(), + }; + } +} export class ValheimWorld extends cdk.Construct { - public service: ecs.FargateService; - public fileSystem: efs.FileSystem; public backupPlan: backup.BackupPlan; + public fileSystem: efs.FileSystem; + public schedules?: ValheimWorldScalingSchedule[]; + public service: ecs.FargateService; constructor(scope: cdk.Construct, id: string, props?: ValheimWorldProps) { super(scope, id); @@ -106,8 +248,6 @@ export class ValheimWorld extends cdk.Construct { lifecyclePolicy: efs.LifecyclePolicy.AFTER_14_DAYS, }); - this.backupPlan = props?.backupPlan ?? this.defaultBackupPlan(); - const volumeConfig = { name: 'valheim-save-data', efsVolumeConfiguration: { @@ -143,7 +283,7 @@ export class ValheimWorld extends cdk.Construct { platformVersion: ecs.FargatePlatformVersion.VERSION1_4, assignPublicIp: true, taskDefinition, - desiredCount: props?.desiredCount ?? 1, + desiredCount: props?.desiredCount, }); // Allow TCP 2049 for EFS @@ -153,6 +293,30 @@ export class ValheimWorld extends cdk.Construct { // Allow UDP 2456-2458 for Valheim this.service.connections.allowFrom(ec2.Peer.anyIpv4(), ec2.Port.udpRange(2456, 2458)); + if (props?.schedules != null) { + let schedules: ValheimWorldScalingSchedule[] = []; + const capacity = props?.desiredCount ?? 1; + const taskCount = this.service.autoScaleTaskCount({ + maxCapacity: capacity, + }); + props?.schedules.forEach((schedule, index) => { + taskCount.scaleOnSchedule(`ValheimWorldStartAt${index}`, { + schedule: appscaling.Schedule.cron(schedule.startAt), + minCapacity: capacity, + maxCapacity: capacity, + }); + taskCount.scaleOnSchedule(`ValheimWorldStopAt${index}`, { + schedule: appscaling.Schedule.cron(schedule.stopAt), + minCapacity: 0, + maxCapacity: 0, + }); + schedules.push(new ValheimWorldScalingSchedule(schedule)); + }); + this.schedules = schedules; + } + + this.backupPlan = props?.backupPlan ?? this.defaultBackupPlan(); + new cdk.CfnOutput(this, 'ValheimServiceArn', { value: this.service.serviceArn, }); @@ -164,13 +328,13 @@ export class ValheimWorld extends cdk.Construct { backupPlan.addSelection('ValheimBackupSelection', { resources: [backup.BackupResource.fromEfsFileSystem(this.fileSystem)], }); - backupPlan.addRule(new backup.BackupPlanRule({ - deleteAfter: cdk.Duration.days(3), - scheduleExpression: events.Schedule.cron({ - minute: '0', - }), - })); - + const defaultSchedule = { toCronOptions: () => { return { minute: '0' }; } }; + for (const schedule of this.schedules ?? [defaultSchedule]) { + backupPlan.addRule(new backup.BackupPlanRule({ + deleteAfter: cdk.Duration.days(3), + scheduleExpression: events.Schedule.cron(schedule.toCronOptions()), + })); + } return backupPlan; } } diff --git a/test/__snapshots__/valheim.test.ts.snap b/test/__snapshots__/valheim.test.ts.snap index f2fa444..73b4fbf 100644 --- a/test/__snapshots__/valheim.test.ts.snap +++ b/test/__snapshots__/valheim.test.ts.snap @@ -228,7 +228,6 @@ Object { "MaximumPercent": 200, "MinimumHealthyPercent": 50, }, - "DesiredCount": 1, "EnableECSManagedTags": false, "LaunchType": "FARGATE", "NetworkConfiguration": Object { diff --git a/test/valheim.test.ts b/test/valheim.test.ts index 1afbbe9..b6db3e1 100644 --- a/test/valheim.test.ts +++ b/test/valheim.test.ts @@ -2,7 +2,7 @@ import { SynthUtils } from '@aws-cdk/assert'; import { Vpc } from '@aws-cdk/aws-ec2'; import { Stack } from '@aws-cdk/core'; -import { ValheimWorld } from '../src'; +import { ValheimWorld, ValheimWorldScalingSchedule } from '../src'; test('ValheimWorldSnapshot', () => { const stack = new Stack(); @@ -10,4 +10,58 @@ test('ValheimWorldSnapshot', () => { new ValheimWorld(stack, 'ValheimWorld', { vpc }); expect(SynthUtils.toCloudFormation(stack)).toMatchSnapshot(); -}); \ No newline at end of file +}); + +test.each` +start | stop | expected +${0} | ${6} | ${[0, 1, 2, 3, 4, 5, 6]} +${0} | ${23} | ${[0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]} +`('range function generates $expected array of numbers', ({ start, stop, expected }) => { + // eslint-disable-next-line + expect(ValheimWorldScalingSchedule['range'](start, stop)).toEqual(expected); +}); + +test.each` +expression | range | expected +${'4'} | ${[0, 1, 2, 3, 4, 5, 6]} | ${Uint8Array.from([4])} +${'1,3,6'} | ${[0, 1, 2, 3, 4, 5, 6]} | ${Uint8Array.from([1, 3, 6])} +${'4-6'} | ${[0, 1, 2, 3, 4, 5, 6]} | ${Uint8Array.from([4, 5, 6])} +`('', ({ expression, range, expected }) => { + // eslint-disable-next-line + expect(ValheimWorldScalingSchedule['toIntArraySorted'](expression, range)).toEqual(expected); +}); + +test.each` +startAt | stopAt | expected +${{ hour: undefined }} | ${{ hour: undefined }} | ${{}} +${{ hour: '0' }} | ${{ hour: '9' }} | ${{ hour: '0-9' }} +${{ hour: '15' }} | ${{ hour: '3' }} | ${{ hour: '15-23,0-3' }} +${{ hour: '3,15' }} | ${{ hour: '9,0' }} | ${{ hour: '3-23,0-0' }} +${{ hour: '0' }} | ${{ hour: '23' }} | ${{ hour: '0-23' }} +${{ weekDay: '2' }} | ${{ weekDay: '4' }} | ${{ weekDay: '2-4' }} +${{ weekDay: '0,3' }} | ${{ weekDay: '0,3' }} | ${{ weekDay: '0,3' }} +${{ weekDay: '0-4' }} | ${{ weekDay: '0-4' }} | ${{ weekDay: '0,1,2,3,4' }} +${{ weekDay: '0,3-5' }} | ${{ weekDay: '0,3-5' }} | ${{ weekDay: '0,3,4,5' }} +${{ weekDay: '5' }} | ${{ weekDay: '0' }} | ${{ weekDay: '5-6,0-0' }} +`('Combines $startAt and $stopAt to generate $expected crontab string', ({ startAt, stopAt, expected }) => { + const schedule = new ValheimWorldScalingSchedule({ startAt, stopAt }); + expect(schedule.toCronOptions()).toEqual(expected); +}); + +test.each` +startAt | stopAt +${{ hour: undefined }} | ${{ hour: '9' }} +${{ hour: '9' }} | ${{ hour: undefined }} +${{ minute: '0', second: '0' }} | ${{ hour: '9' }} +${{ hour: '' }} | ${{ hour: '9' }} +${{ hour: 'a' }} | ${{ hour: '9' }} +${{ hour: '0/1' }} | ${{ hour: '9' }} +${{ hour: '0' }} | ${{ hour: '9-18' }} +${{ hour: '0' }} | ${{ hour: '9,10' }} +`('Invalid $startAt and $stopAt causes Error', ({ startAt, stopAt }) => { + const schedule = new ValheimWorldScalingSchedule({ startAt, stopAt }); + expect(() => { + schedule.toCronOptions(); + }).toThrow(); +}); +