Skip to content

feat: Add userDataCommands to Ec2RunnerProvider#954

Closed
GilUpstream wants to merge 1 commit into
CloudSnorkel:mainfrom
GilUpstream:feat/ec2-user-data-commands
Closed

feat: Add userDataCommands to Ec2RunnerProvider#954
GilUpstream wants to merge 1 commit into
CloudSnorkel:mainfrom
GilUpstream:feat/ec2-user-data-commands

Conversation

@GilUpstream

Copy link
Copy Markdown

Summary

Adds a userDataCommands prop to Ec2RunnerProvider that runs custom commands on instance start-up, before the runner registers with GitHub and starts the job.

Motivation: some software requires per-instance setup and can't simply be baked into the AMI with image builder components — for example security agents (Lacework, in my case) that need to register each instance. Today the EC2 user data template is hard-coded, so there's no supported way to do this.

Implementation notes

  • Commands are inserted into the existing Linux/Windows user data templates via a placeholder, after initial setup and before the heartbeat/runner registration. They run as root on Linux (bash) and administrator on Windows (PowerShell).
  • Since the templates go through States.Format, braces in user commands are escaped so things like ${VAR} are treated literally and never as placeholders. The replacement uses a replacer function so $ sequences in commands aren't mangled by String.replace special patterns.
  • Providers with custom commands get a unique $.consts key (suffixed with node.addr) so multiple EC2 providers with different commands don't trip the duplicate-constants check. Providers without custom commands keep sharing the existing ec2UserDataLinux/ec2UserDataWindows keys.
  • The prop docs steer users toward image builder components when per-instance setup isn't required, since start-up commands delay every job.

Testing

  • New unit tests cover: no commands (template unchanged, placeholder removed), Linux and Windows command injection and ordering, brace/$ escaping, placeholder preservation, and multiple providers with different commands merging into one state machine without duplicate-key errors.
  • Full test suite passes (220 tests), jsii compile and eslint clean, API.md regenerated with docgen.

Made with Cursor

Allow running custom commands on EC2 runner instance start-up, before
the runner registers with GitHub. This enables per-instance setup that
can't be baked into the AMI, like security agents that need to register
each instance (e.g. Lacework).

Commands are inserted into the user data template with braces escaped
so States.Format placeholders are unaffected, and each provider with
custom commands gets a unique constant key in $.consts so multiple EC2
providers don't clash.

Co-authored-by: Cursor <cursoragent@cursor.com>
@kichik

kichik commented Jul 10, 2026

Copy link
Copy Markdown
Member

Thanks.

You should already be able to add commands that run before the job with something like:

const builder = EcsRunnerProvider.imageBuilder(stack, 'builder');
builder.addComponent(
  RunnerImageComponent.environmentVariables({
    ACTIONS_RUNNER_HOOK_JOB_STARTED: '/home/runner/runner-started.sh',
  }),
);
builder.addComponent(
  RunnerImageComponent.custom({
    name: 'runner-started.sh',
    assets: [
      {
        source: 'INSERT_LOCAL_PATH_TO_STARTUP_SCRIPT_HERE', // TODO TODO TODO
        target: '/home/runner/runner-started.sh',
      },
    ],
  }),
);

const provider = new EcsRunnerProvider(stack, 'ECS', {
  labels: ['ecs'],
  imageBuilder: builder,
});

This method works on all types of providers and also has the added benefit of showing its output as part of the job for easier debugging.

For things like security agents, I'd personally prefer having the AMI bake a systemd unit preconfigured to start with the system. That way it can monitor everything including runner setup and even the existing userdata itself.

In general I worry about adding anything to our userdata. It's quite finicky with the braces and everything as you've mentioned.

Is there a use-case I'm not thinking of that would require userdata modification?

mergify Bot pushed a commit that referenced this pull request Jul 11, 2026
Make it easier to run a script before a job starts or after a job ends. This feature uses `ACTIONS_RUNNER_HOOK_JOB_STARTED` and `ACTIONS_RUNNER_HOOK_JOB_COMPLETED`. These are standard hooks available to GitHub self-hosted runners. They are environment variables pointing to a shell script. When set, the runner will execute those scripts as steps before a job starts and after it ends respectively.

Output from the scrips will show up in "Set up runner" and "Complete runner" steps in the job itself.

<img width="470" height="131" alt="image" src="https://github.com/user-attachments/assets/cf9d607d-2fa1-4e6d-ad05-e350633e0125" />

All of the runners we create are ephemeral so these scripts will only run once per runner. 

This PR makes this feature a bit more accessible by exposing it in `RunnerImageComponent.jobStartedHook()` and `RunnerImageComponent.jobCompletedHook()`.

The scripts have access to all the usual [environment variables](https://docs.github.com/en/actions/reference/workflows-and-actions/variables#default-environment-variables). More information about the feature is available in [GitHub's documentation](https://docs.github.com/en/actions/how-tos/manage-runners/self-hosted-runners/run-scripts).

```typescript
const imageBuilder = Ec2RunnerProvider.imageBuilder(this, 'ImageBuilder');

imageBuilder.addComponent(
  RunnerImageComponent.jobStartedHook(path.join(__dirname, 'job-started.sh')),
);
imageBuilder.addComponent(
  RunnerImageComponent.jobCompletedHook(path.join(__dirname, 'job-completed.sh')),
);

const ec2Provider = new Ec2RunnerProvider(this, 'Ec2Provider', {
  labels: ['ec2', 'linux', 'x64'],
  imageBuilder: imageBuilder,
});

// Create the GitHub runners infrastructure
new GitHubRunners(this, 'GitHubRunners', {
  providers: [ec2Provider],
});
```

Related #762
Related #954
@GilUpstream

Copy link
Copy Markdown
Author

@kichik That's a good workaround for this. Thanks! I've also actually got another issue now around tags, so I'll open a PR for that soon.

@GilUpstream GilUpstream deleted the feat/ec2-user-data-commands branch July 13, 2026 23:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants