Skip to content

Commit f3fe8e1

Browse files
authored
feat(ec2): CFN-init support for systemd (#24683)
CFN-init can be used to install software onto EC2 Instances that are created using CloudFormation. CFN init supports SystemD, but this was not yet available in CDK. This PR adds support for SystemD. It also adds a helper function to create a simple SystemD config files for your own services. ---- *By submitting this pull request, I confirm that my contribution is made under the terms of the Apache-2.0 license*
1 parent 3b7431b commit f3fe8e1

12 files changed

+494
-65
lines changed

packages/@aws-cdk/aws-ec2/README.md

+42
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,48 @@ new ec2.Instance(this, 'Instance', {
11511151
});
11521152
```
11531153

1154+
`InitCommand` can not be used to start long-running processes. At deploy time,
1155+
`cfn-init` will always wait for the process to exit before continuing, causing
1156+
the CloudFormation deployment to fail because the signal hasn't been received
1157+
within the expected timeout.
1158+
1159+
Instead, you should install a service configuration file onto your machine `InitFile`,
1160+
and then use `InitService` to start it.
1161+
1162+
If your Linux OS is using SystemD (like Amazon Linux 2 or higher), the CDK has
1163+
helpers to create a long-running service using CFN Init. You can create a
1164+
SystemD-compatible config file using `InitService.systemdConfigFile()`, and
1165+
start it immediately. The following examples shows how to start a trivial Python
1166+
3 web server:
1167+
1168+
```ts
1169+
declare const vpc: ec2.Vpc;
1170+
declare const instanceType: ec2.InstanceType;
1171+
1172+
new ec2.Instance(this, 'Instance', {
1173+
vpc,
1174+
instanceType,
1175+
machineImage: ec2.MachineImage.latestAmazonLinux({
1176+
// Amazon Linux 2 uses SystemD
1177+
generation: ec2.AmazonLinuxGeneration: AMAZON_LINUX_2,
1178+
}),
1179+
1180+
init: ec2.CloudFormationInit.fromElements([
1181+
// Create a simple config file that runs a Python web server
1182+
ec2.InitService.systemdConfigFile('simpleserver', {
1183+
command: '/usr/bin/python3 -m http.server 8080',
1184+
cwd: '/var/www/html',
1185+
}),
1186+
// Start the server using SystemD
1187+
ec2.InitService.enable('simpleserver', {
1188+
serviceManager: ec2.ServiceManager.SYSTEMD,
1189+
}),
1190+
// Drop an example file to show the web server working
1191+
ec2.InitFile.fromString('/var/www/html/index.html', 'Hello! It\'s working!'),
1192+
]),
1193+
});
1194+
```
1195+
11541196
You can have services restarted after the init process has made changes to the system.
11551197
To do that, instantiate an `InitServiceRestartHandle` and pass it to the config elements
11561198
that need to trigger the restart and the service itself. For example, the following

packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts

+136-2
Original file line numberDiff line numberDiff line change
@@ -781,6 +781,16 @@ export interface InitServiceOptions {
781781
* @default - No files trigger restart
782782
*/
783783
readonly serviceRestartHandle?: InitServiceRestartHandle;
784+
785+
/**
786+
* What service manager to use
787+
*
788+
* This needs to match the actual service manager on your Operating System.
789+
* For example, Amazon Linux 1 uses SysVinit, but Amazon Linux 2 uses Systemd.
790+
*
791+
* @default ServiceManager.SYSVINIT for Linux images, ServiceManager.WINDOWS for Windows images
792+
*/
793+
readonly serviceManager?: ServiceManager;
784794
}
785795

786796
/**
@@ -806,6 +816,39 @@ export class InitService extends InitElement {
806816
return new InitService(serviceName, { enabled: false, ensureRunning: false });
807817
}
808818

819+
/**
820+
* Install a systemd-compatible config file for the given service
821+
*
822+
* This is a helper function to create a simple systemd configuration
823+
* file that will allow running a service on the machine using `InitService.enable()`.
824+
*
825+
* Systemd allows many configuration options; this function does not pretend
826+
* to expose all of them. If you need advanced configuration options, you
827+
* can use `InitFile` to create exactly the configuration file you need
828+
* at `/etc/systemd/system/${serviceName}.service`.
829+
*/
830+
public static systemdConfigFile(serviceName: string, options: SystemdConfigFileOptions): InitFile {
831+
if (!options.command.startsWith('/')) {
832+
throw new Error(`SystemD executables must use an absolute path, got '${options.command}'`);
833+
}
834+
835+
const lines = [
836+
'[Unit]',
837+
...(options.description ? [`Description=${options.description}`] : []),
838+
...(options.afterNetwork ?? true ? ['After=network.target'] : []),
839+
'[Service]',
840+
`ExecStart=${options.command}`,
841+
...(options.cwd ? [`WorkingDirectory=${options.cwd}`] : []),
842+
...(options.user ? [`User=${options.user}`] : []),
843+
...(options.group ? [`Group=${options.user}`] : []),
844+
...(options.keepRunning ?? true ? ['Restart=always'] : []),
845+
'[Install]',
846+
'WantedBy=multi-user.target',
847+
];
848+
849+
return InitFile.fromString(`/etc/systemd/system/${serviceName}.service`, lines.join('\n'));
850+
}
851+
809852
public readonly elementType = InitElementType.SERVICE.toString();
810853

811854
private constructor(private readonly serviceName: string, private readonly serviceOptions: InitServiceOptions) {
@@ -814,11 +857,12 @@ export class InitService extends InitElement {
814857

815858
/** @internal */
816859
public _bind(options: InitBindOptions): InitElementConfig {
817-
const serviceManager = options.platform === InitPlatform.LINUX ? 'sysvinit' : 'windows';
860+
const serviceManager = this.serviceOptions.serviceManager
861+
?? (options.platform === InitPlatform.LINUX ? ServiceManager.SYSVINIT : ServiceManager.WINDOWS);
818862

819863
return {
820864
config: {
821-
[serviceManager]: {
865+
[serviceManagerToString(serviceManager)]: {
822866
[this.serviceName]: {
823867
enabled: this.serviceOptions.enabled,
824868
ensureRunning: this.serviceOptions.ensureRunning,
@@ -970,3 +1014,93 @@ function standardS3Auth(role: iam.IRole, bucketName: string) {
9701014
},
9711015
};
9721016
}
1017+
1018+
/**
1019+
* The service manager that will be used by InitServices
1020+
*
1021+
* The value needs to match the service manager used by your operating
1022+
* system.
1023+
*/
1024+
export enum ServiceManager {
1025+
/**
1026+
* Use SysVinit
1027+
*
1028+
* This is the default for Linux systems.
1029+
*/
1030+
SYSVINIT,
1031+
1032+
/**
1033+
* Use Windows
1034+
*
1035+
* This is the default for Windows systems.
1036+
*/
1037+
WINDOWS,
1038+
1039+
/**
1040+
* Use systemd
1041+
*/
1042+
SYSTEMD,
1043+
}
1044+
1045+
function serviceManagerToString(x: ServiceManager): string {
1046+
switch (x) {
1047+
case ServiceManager.SYSTEMD: return 'systemd';
1048+
case ServiceManager.SYSVINIT: return 'sysvinit';
1049+
case ServiceManager.WINDOWS: return 'windows';
1050+
}
1051+
}
1052+
1053+
/**
1054+
* Options for creating a SystemD configuration file
1055+
*/
1056+
export interface SystemdConfigFileOptions {
1057+
/**
1058+
* The command to run to start this service
1059+
*/
1060+
readonly command: string;
1061+
1062+
/**
1063+
* The working directory for the command
1064+
*
1065+
* @default Root directory or home directory of specified user
1066+
*/
1067+
readonly cwd?: string;
1068+
1069+
/**
1070+
* A description of this service
1071+
*
1072+
* @default - No description
1073+
*/
1074+
readonly description?: string;
1075+
1076+
/**
1077+
* The user to execute the process under
1078+
*
1079+
* @default root
1080+
*/
1081+
readonly user?: string;
1082+
1083+
/**
1084+
* The group to execute the process under
1085+
*
1086+
* @default root
1087+
*/
1088+
readonly group?: string;
1089+
1090+
/**
1091+
* Keep the process running all the time
1092+
*
1093+
* Restarts the process when it exits for any reason other
1094+
* than the machine shutting down.
1095+
*
1096+
* @default true
1097+
*/
1098+
readonly keepRunning?: boolean;
1099+
1100+
/**
1101+
* Start the service after the networking part of the OS comes up
1102+
*
1103+
* @default true
1104+
*/
1105+
readonly afterNetwork?: boolean;
1106+
}

packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts

+45
Original file line numberDiff line numberDiff line change
@@ -625,6 +625,51 @@ describe('InitService', () => {
625625
});
626626
});
627627

628+
test('can request systemd service', () => {
629+
// WHEN
630+
const service = ec2.InitService.enable('httpd', {
631+
serviceManager: ec2.ServiceManager.SYSTEMD,
632+
});
633+
634+
// THEN
635+
const bindOptions = defaultOptions(InitPlatform.LINUX);
636+
const rendered = service._bind(bindOptions).config;
637+
638+
// THEN
639+
expect(rendered.systemd).toEqual({
640+
httpd: {
641+
enabled: true,
642+
ensureRunning: true,
643+
},
644+
});
645+
});
646+
647+
test('can create simple systemd config file', () => {
648+
// WHEN
649+
const file = ec2.InitService.systemdConfigFile('myserver', {
650+
command: '/start/my/service',
651+
cwd: '/my/dir',
652+
user: 'ec2-user',
653+
group: 'ec2-user',
654+
description: 'my service',
655+
});
656+
657+
// THEN
658+
const bindOptions = defaultOptions(InitPlatform.LINUX);
659+
const rendered = file._bind(bindOptions).config;
660+
expect(rendered).toEqual({
661+
'/etc/systemd/system/myserver.service': expect.objectContaining({
662+
content: expect.any(String),
663+
}),
664+
});
665+
666+
const capture = rendered['/etc/systemd/system/myserver.service'].content;
667+
expect(capture).toContain('ExecStart=/start/my/service');
668+
expect(capture).toContain('WorkingDirectory=/my/dir');
669+
expect(capture).toContain('User=ec2-user');
670+
expect(capture).toContain('Group=ec2-user');
671+
expect(capture).toContain('Description=my service');
672+
});
628673
});
629674

630675
describe('InitSource', () => {

packages/@aws-cdk/aws-elasticloadbalancing/package.json

+1
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@
8484
"@aws-cdk/cdk-build-tools": "0.0.0",
8585
"@aws-cdk/integ-runner": "0.0.0",
8686
"@aws-cdk/integ-tests": "0.0.0",
87+
"@aws-cdk/aws-iam": "0.0.0",
8788
"@aws-cdk/cfn2ts": "0.0.0",
8889
"@aws-cdk/pkglint": "0.0.0",
8990
"@types/jest": "^27.5.2"

packages/@aws-cdk/aws-elasticloadbalancing/test/integ.instanceTarget.elb.js.snapshot/InstanceTargetTestDefaultTestDeployAssertAF607556.assets.json

+1-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"version": "29.0.0",
2+
"version": "31.0.0",
33
"files": {
44
"21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": {
55
"source": {

packages/@aws-cdk/aws-elasticloadbalancing/test/integ.instanceTarget.elb.js.snapshot/aws-cdk-elb-instance-target-integ.assets.json

+3-3
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,15 @@
11
{
2-
"version": "29.0.0",
2+
"version": "31.0.0",
33
"files": {
4-
"11ca0111a871a53be970c5db0c5a24d4146213fd59f6d172b6fc1bc3de206cf9": {
4+
"c8ab3e4e4503281b1f7df3028abab9a0ca3738640d31201b5118a18aaa225eab": {
55
"source": {
66
"path": "aws-cdk-elb-instance-target-integ.template.json",
77
"packaging": "file"
88
},
99
"destinations": {
1010
"current_account-current_region": {
1111
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
12-
"objectKey": "11ca0111a871a53be970c5db0c5a24d4146213fd59f6d172b6fc1bc3de206cf9.json",
12+
"objectKey": "c8ab3e4e4503281b1f7df3028abab9a0ca3738640d31201b5118a18aaa225eab.json",
1313
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
1414
}
1515
}

0 commit comments

Comments
 (0)