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();
+});
+