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

feat(ec2): CFN-init support for systemd #24683

Merged
merged 5 commits into from
Mar 21, 2023
Merged
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
42 changes: 42 additions & 0 deletions packages/@aws-cdk/aws-ec2/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1151,6 +1151,48 @@ new ec2.Instance(this, 'Instance', {
});
```

`InitCommand` can not be used to start long-running processes. At deploy time,
`cfn-init` will always wait for the process to exit before continuing, causing
the CloudFormation deployment to fail because the signal hasn't been received
within the expected timeout.

Instead, you should install a service configuration file onto your machine `InitFile`,
and then use `InitService` to start it.

If your Linux OS is using SystemD (like Amazon Linux 2 or higher), the CDK has
helpers to create a long-running service using CFN Init. You can create a
SystemD-compatible config file using `InitService.systemdConfigFile()`, and
start it immediately. The following examples shows how to start a trivial Python
3 web server:

```ts
declare const vpc: ec2.Vpc;
declare const instanceType: ec2.InstanceType;

new ec2.Instance(this, 'Instance', {
vpc,
instanceType,
machineImage: ec2.MachineImage.latestAmazonLinux({
// Amazon Linux 2 uses SystemD
generation: ec2.AmazonLinuxGeneration: AMAZON_LINUX_2,
}),

init: ec2.CloudFormationInit.fromElements([
// Create a simple config file that runs a Python web server
ec2.InitService.systemdConfigFile('simpleserver', {
command: '/usr/bin/python3 -m http.server 8080',
cwd: '/var/www/html',
}),
// Start the server using SystemD
ec2.InitService.enable('simpleserver', {
serviceManager: ec2.ServiceManager.SYSTEMD,
}),
// Drop an example file to show the web server working
ec2.InitFile.fromString('/var/www/html/index.html', 'Hello! It\'s working!'),
]),
});
```

You can have services restarted after the init process has made changes to the system.
To do that, instantiate an `InitServiceRestartHandle` and pass it to the config elements
that need to trigger the restart and the service itself. For example, the following
Expand Down
138 changes: 136 additions & 2 deletions packages/@aws-cdk/aws-ec2/lib/cfn-init-elements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,6 +781,16 @@ export interface InitServiceOptions {
* @default - No files trigger restart
*/
readonly serviceRestartHandle?: InitServiceRestartHandle;

/**
* What service manager to use
*
* This needs to match the actual service manager on your Operating System.
* For example, Amazon Linux 1 uses SysVinit, but Amazon Linux 2 uses Systemd.
*
* @default ServiceManager.SYSVINIT for Linux images, ServiceManager.WINDOWS for Windows images
*/
readonly serviceManager?: ServiceManager;
}

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

/**
* Install a systemd-compatible config file for the given service
*
* This is a helper function to create a simple systemd configuration
* file that will allow running a service on the machine using `InitService.enable()`.
*
* Systemd allows many configuration options; this function does not pretend
* to expose all of them. If you need advanced configuration options, you
* can use `InitFile` to create exactly the configuration file you need
* at `/etc/systemd/system/${serviceName}.service`.
*/
public static systemdConfigFile(serviceName: string, options: SystemdConfigFileOptions): InitFile {
if (!options.command.startsWith('/')) {
throw new Error(`SystemD executables must use an absolute path, got '${options.command}'`);
}

const lines = [
'[Unit]',
...(options.description ? [`Description=${options.description}`] : []),
...(options.afterNetwork ?? true ? ['After=network.target'] : []),
'[Service]',
`ExecStart=${options.command}`,
...(options.cwd ? [`WorkingDirectory=${options.cwd}`] : []),
...(options.user ? [`User=${options.user}`] : []),
...(options.group ? [`Group=${options.user}`] : []),
...(options.keepRunning ?? true ? ['Restart=always'] : []),
'[Install]',
'WantedBy=multi-user.target',
];

return InitFile.fromString(`/etc/systemd/system/${serviceName}.service`, lines.join('\n'));
}

public readonly elementType = InitElementType.SERVICE.toString();

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

/** @internal */
public _bind(options: InitBindOptions): InitElementConfig {
const serviceManager = options.platform === InitPlatform.LINUX ? 'sysvinit' : 'windows';
const serviceManager = this.serviceOptions.serviceManager
?? (options.platform === InitPlatform.LINUX ? ServiceManager.SYSVINIT : ServiceManager.WINDOWS);

return {
config: {
[serviceManager]: {
[serviceManagerToString(serviceManager)]: {
[this.serviceName]: {
enabled: this.serviceOptions.enabled,
ensureRunning: this.serviceOptions.ensureRunning,
Expand Down Expand Up @@ -970,3 +1014,93 @@ function standardS3Auth(role: iam.IRole, bucketName: string) {
},
};
}

/**
* The service manager that will be used by InitServices
*
* The value needs to match the service manager used by your operating
* system.
*/
export enum ServiceManager {
/**
* Use SysVinit
*
* This is the default for Linux systems.
*/
SYSVINIT,

/**
* Use Windows
*
* This is the default for Windows systems.
*/
WINDOWS,

/**
* Use systemd
*/
SYSTEMD,
}

function serviceManagerToString(x: ServiceManager): string {
switch (x) {
case ServiceManager.SYSTEMD: return 'systemd';
case ServiceManager.SYSVINIT: return 'sysvinit';
case ServiceManager.WINDOWS: return 'windows';
}
}

/**
* Options for creating a SystemD configuration file
*/
export interface SystemdConfigFileOptions {
/**
* The command to run to start this service
*/
readonly command: string;

/**
* The working directory for the command
*
* @default Root directory or home directory of specified user
*/
readonly cwd?: string;

/**
* A description of this service
*
* @default - No description
*/
readonly description?: string;

/**
* The user to execute the process under
*
* @default root
*/
readonly user?: string;

/**
* The group to execute the process under
*
* @default root
*/
readonly group?: string;

/**
* Keep the process running all the time
*
* Restarts the process when it exits for any reason other
* than the machine shutting down.
*
* @default true
*/
readonly keepRunning?: boolean;

/**
* Start the service after the networking part of the OS comes up
*
* @default true
*/
readonly afterNetwork?: boolean;
}
45 changes: 45 additions & 0 deletions packages/@aws-cdk/aws-ec2/test/cfn-init-element.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -625,6 +625,51 @@ describe('InitService', () => {
});
});

test('can request systemd service', () => {
// WHEN
const service = ec2.InitService.enable('httpd', {
serviceManager: ec2.ServiceManager.SYSTEMD,
});

// THEN
const bindOptions = defaultOptions(InitPlatform.LINUX);
const rendered = service._bind(bindOptions).config;

// THEN
expect(rendered.systemd).toEqual({
httpd: {
enabled: true,
ensureRunning: true,
},
});
});

test('can create simple systemd config file', () => {
// WHEN
const file = ec2.InitService.systemdConfigFile('myserver', {
command: '/start/my/service',
cwd: '/my/dir',
user: 'ec2-user',
group: 'ec2-user',
description: 'my service',
});

// THEN
const bindOptions = defaultOptions(InitPlatform.LINUX);
const rendered = file._bind(bindOptions).config;
expect(rendered).toEqual({
'/etc/systemd/system/myserver.service': expect.objectContaining({
content: expect.any(String),
}),
});

const capture = rendered['/etc/systemd/system/myserver.service'].content;
expect(capture).toContain('ExecStart=/start/my/service');
expect(capture).toContain('WorkingDirectory=/my/dir');
expect(capture).toContain('User=ec2-user');
expect(capture).toContain('Group=ec2-user');
expect(capture).toContain('Description=my service');
});
});

describe('InitSource', () => {
Expand Down
1 change: 1 addition & 0 deletions packages/@aws-cdk/aws-elasticloadbalancing/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@
"@aws-cdk/cdk-build-tools": "0.0.0",
"@aws-cdk/integ-runner": "0.0.0",
"@aws-cdk/integ-tests": "0.0.0",
"@aws-cdk/aws-iam": "0.0.0",
"@aws-cdk/cfn2ts": "0.0.0",
"@aws-cdk/pkglint": "0.0.0",
"@types/jest": "^27.5.2"
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"version": "29.0.0",
"version": "31.0.0",
"files": {
"21fbb51d7b23f6a6c262b46a9caee79d744a3ac019fd45422d988b96d44b2a22": {
"source": {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
{
"version": "29.0.0",
"version": "31.0.0",
"files": {
"11ca0111a871a53be970c5db0c5a24d4146213fd59f6d172b6fc1bc3de206cf9": {
"c8ab3e4e4503281b1f7df3028abab9a0ca3738640d31201b5118a18aaa225eab": {
"source": {
"path": "aws-cdk-elb-instance-target-integ.template.json",
"packaging": "file"
},
"destinations": {
"current_account-current_region": {
"bucketName": "cdk-hnb659fds-assets-${AWS::AccountId}-${AWS::Region}",
"objectKey": "11ca0111a871a53be970c5db0c5a24d4146213fd59f6d172b6fc1bc3de206cf9.json",
"objectKey": "c8ab3e4e4503281b1f7df3028abab9a0ca3738640d31201b5118a18aaa225eab.json",
"assumeRoleArn": "arn:${AWS::Partition}:iam::${AWS::AccountId}:role/cdk-hnb659fds-file-publishing-role-${AWS::AccountId}-${AWS::Region}"
}
}
Expand Down
Loading