Skip to content
Draft
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
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ To be able to support a number of use-cases, the module has quite a lot of confi

## AWS SSM Parameters

The module uses the AWS System Manager Parameter Store to store configuration for the runners, as well as registration tokens and secrets for the Lambdas. Paths for the parameters can be configured via the variable `ssm_paths`. The location of the configuration parameters is retrieved by the runners via the instance tag `ghr:ssm_config_path`. The following default paths will be used. Tokens or JIT config stored in the token path will be deleted after retrieval by instance, data not deleted after a day will be deleted by a SSM housekeeper lambda.
The module uses the AWS System Manager Parameter Store to store configuration for the runners, as well as registration tokens and secrets for the Lambdas. Paths for the parameters can be configured via the variable `ssm_paths`. The location of the configuration parameters is retrieved by the runners via the instance tag `ghr:ssm_config_path`. The following default paths will be used. Tokens or JIT config stored in the token path will be deleted after retrieval by instance, data not deleted after a day will be deleted by a SSM housekeeper lambda. Alternatively you can set `ssm_token_ttl_seconds` to attach a native SSM expiration policy to the token / JIT config parameters so SSM deletes leftovers itself after the TTL passes. Be aware that parameter policies require the Advanced parameter tier for every token parameter, which incurs additional costs, and that expiration is enforced asynchronously by SSM. The housekeeper lambda remains active as a backstop.

Furthermore, to accommodate larger JIT configurations or other stored values, the module implements automatic tier selection for SSM parameters:

Expand Down
1 change: 1 addition & 0 deletions lambdas/functions/control-plane/src/modules.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ declare namespace NodeJS {
RUNNER_PROVIDER_TYPE?: string;
SCALE_DOWN_CONFIG: string;
SSM_TOKEN_PATH: string;
SSM_TOKEN_TTL_SECONDS: string;
SSM_CLEANUP_CONFIG: string;
SUBNET_IDS: string;
INSTANCE_TYPES: string;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,8 @@ vi.mock('../github/auth', () => ({
createOctokitClient: vi.fn(),
}));

vi.mock('../scale-runners/github-runner', () => ({
vi.mock('../scale-runners/github-runner', async (importActual) => ({
...(await importActual<typeof import('../scale-runners/github-runner')>()),
createStartRunnerConfig: vi.fn(),
getGitHubEnterpriseApiUrl: vi.fn(),
validateSsmParameterStoreTags: vi.fn(),
Expand Down
3 changes: 2 additions & 1 deletion lambdas/functions/control-plane/src/pool/pool.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,8 @@ vi.mock('@aws-github-runner/runner-providers/aws/ec2/control-plane/runner-config
createRunners: vi.fn(),
}));

vi.mock('../scale-runners/github-runner', async () => ({
vi.mock('../scale-runners/github-runner', async (importActual) => ({
...(await importActual<typeof import('../scale-runners/github-runner')>()),
createStartRunnerConfig: vi.fn(),
getGitHubEnterpriseApiUrl: vi.fn().mockReturnValue({
ghesApiUrl: '',
Expand Down
8 changes: 7 additions & 1 deletion lambdas/functions/control-plane/src/pool/pool.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import yn from 'yn';

import { createGithubAppAuth, createGithubInstallationAuth, createOctokitClient } from '../github/auth';
import { controlPlaneProviderRegistry } from '../control-plane-providers';
import { getGitHubEnterpriseApiUrl, validateSsmParameterStoreTags } from '../scale-runners/github-runner';
import {
getGitHubEnterpriseApiUrl,
parseSsmTokenTtlSeconds,
validateSsmParameterStoreTags,
} from '../scale-runners/github-runner';
import type { RunnerStatus } from './pool-provider';

const logger = createChildLogger('pool');
Expand All @@ -27,6 +31,7 @@ export async function adjust(event: PoolEvent): Promise<void> {
const runnerNamePrefix = process.env.RUNNER_NAME_PREFIX || '';
const environment = process.env.ENVIRONMENT;
const ssmTokenPath = process.env.SSM_TOKEN_PATH;
const ssmTokenTtlSeconds = parseSsmTokenTtlSeconds(process.env.SSM_TOKEN_TTL_SECONDS);
const ssmConfigPath = process.env.SSM_CONFIG_PATH || '';
const ephemeral = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false });
const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeral });
Expand Down Expand Up @@ -93,6 +98,7 @@ export async function adjust(event: PoolEvent): Promise<void> {
runnerType: 'Org',
disableAutoUpdate: disableAutoUpdate,
ssmTokenPath,
ssmTokenTtlSeconds,
ssmConfigPath,
ssmParameterStoreTags,
},
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { describe, expect, it } from 'vitest';

import { parseSsmTokenTtlSeconds } from './github-runner';

describe('parseSsmTokenTtlSeconds', () => {
it.each([
[undefined, undefined],
['', undefined],
[' ', undefined],
['3600', 3600],
['1', 1],
])('parses %j to %j', (input, expected) => {
expect(parseSsmTokenTtlSeconds(input)).toBe(expected);
});

it.each([['not-a-number'], ['0'], ['-10']])('throws on invalid value %j', (input) => {
expect(() => parseSsmTokenTtlSeconds(input)).toThrow('SSM_TOKEN_TTL_SECONDS must be a positive number');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,17 @@ export function validateSsmParameterStoreTags(tagsJson: string): { Key: string;
}
}

export function parseSsmTokenTtlSeconds(ttl: string | undefined): number | undefined {
if (!ttl || ttl.trim() === '') {
return undefined;
}
const ttlSeconds = parseInt(ttl);
if (isNaN(ttlSeconds) || ttlSeconds <= 0) {
throw new Error(`SSM_TOKEN_TTL_SECONDS must be a positive number, got "${ttl}"`);
}
return ttlSeconds;
}

async function getGithubRunnerRegistrationToken(githubRunnerConfig: CreateGitHubRunnerConfig, ghClient: Octokit) {
const registrationToken =
githubRunnerConfig.runnerType === 'Org'
Expand Down Expand Up @@ -270,6 +281,7 @@ async function createRegistrationTokenConfig(
for (const runnerId of runnerIds) {
await putParameter(`${githubRunnerConfig.ssmTokenPath}/${runnerId}`, runnerServiceConfig.join(' '), true, {
tags: [...(options.getSsmParameterTags?.(runnerId) ?? []), ...githubRunnerConfig.ssmParameterStoreTags],
ttlSeconds: githubRunnerConfig.ssmTokenTtlSeconds,
});
if (isDelay) {
// Delay to prevent AWS ssm rate limits by being within the max throughput limit
Expand Down Expand Up @@ -337,6 +349,7 @@ async function createJitConfig(
});
await putParameter(`${githubRunnerConfig.ssmTokenPath}/${runnerId}`, runnerConfig.data.encoded_jit_config, true, {
tags: [...(options.getSsmParameterTags?.(runnerId) ?? []), ...githubRunnerConfig.ssmParameterStoreTags],
ttlSeconds: githubRunnerConfig.ssmTokenTtlSeconds,
});
if (isDelay) {
// Delay to prevent AWS ssm rate limits by being within the max throughput limit
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
resolveInstallationId,
isJobQueued,
UnsupportedEventError,
parseSsmTokenTtlSeconds,
validateSsmParameterStoreTags,
} from './github-runner';
import { publishRetryMessage } from './job-retry';
Expand Down Expand Up @@ -76,6 +77,7 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise<stri
const runnerLabels = process.env.RUNNER_LABELS || '';
const runnerGroup = process.env.RUNNER_GROUP_NAME || 'Default';
const ssmTokenPath = process.env.SSM_TOKEN_PATH;
const ssmTokenTtlSeconds = parseSsmTokenTtlSeconds(process.env.SSM_TOKEN_TTL_SECONDS);
const ephemeralEnabled = yn(process.env.ENABLE_EPHEMERAL_RUNNERS, { default: false });
const enableJitConfig = yn(process.env.ENABLE_JIT_CONFIG, { default: ephemeralEnabled });
const disableAutoUpdate = yn(process.env.DISABLE_RUNNER_AUTOUPDATE, { default: false });
Expand Down Expand Up @@ -309,6 +311,7 @@ export async function scaleUp(payloads: ActionRequestMessageSQS[]): Promise<stri
runnerType,
disableAutoUpdate,
ssmTokenPath,
ssmTokenTtlSeconds,
ssmConfigPath,
ssmParameterStoreTags,
};
Expand Down
46 changes: 46 additions & 0 deletions lambdas/libs/aws-ssm-util/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,52 @@ describe('Test getParameter and putParameter', () => {
Tier: expectedTier,
});
});

it('Puts parameters without an expiration policy when no TTL is given', async () => {
// Arrange
mockSSMClient.on(PutParameterCommand).resolves({ $metadata: { httpStatusCode: 200 } });

// Act
await putParameter('testParam', 'test', false);

// Assert
expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, {
Name: 'testParam',
Value: 'test',
Type: 'String',
Tier: 'Standard',
Policies: undefined,
});
});

it('Puts parameters with an expiration policy and forces Advanced tier when a TTL is given', async () => {
// Arrange
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-01-01T00:00:00.000Z'));
mockSSMClient.on(PutParameterCommand).resolves({ $metadata: { httpStatusCode: 200 } });

try {
// Act
await putParameter('testParam', 'test', true, { ttlSeconds: 3600 });

// Assert
expect(mockSSMClient).toHaveReceivedCommandWith(PutParameterCommand, {
Name: 'testParam',
Value: 'test',
Type: 'SecureString',
Tier: 'Advanced',
Policies: JSON.stringify([
{
Type: 'Expiration',
Version: '1.0',
Attributes: { Timestamp: '2026-01-01T01:00:00.000Z' },
},
]),
});
} finally {
vi.useRealTimers();
}
});
});

describe('Test getParameters (batch)', () => {
Expand Down
21 changes: 19 additions & 2 deletions lambdas/libs/aws-ssm-util/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,20 +109,37 @@ export async function putParameter(
parameter_name: string,
parameter_value: string,
secure: boolean,
options: { tags?: Tag[] } = {},
options: { tags?: Tag[]; ttlSeconds?: number } = {},
): Promise<void> {
const client = ssmClient();

// Determine tier based on parameter_value size
const valueSizeBytes = Buffer.byteLength(parameter_value, 'utf8');

// Parameter policies (e.g. Expiration) are only supported on the Advanced
// tier, so a TTL forces the tier regardless of the value size. Expiration is
// enforced asynchronously by SSM: treat it as cleanup, not a security boundary.
const expiration =
options.ttlSeconds !== undefined
? JSON.stringify([
{
Type: 'Expiration',
Version: '1.0',
Attributes: {
Timestamp: new Date(Date.now() + options.ttlSeconds * 1000).toISOString(),
},
},
])
: undefined;

await client.send(
new PutParameterCommand({
Name: parameter_name,
Value: parameter_value,
Type: secure ? 'SecureString' : 'String',
Tags: options.tags,
Tier: valueSizeBytes >= SSM_ADVANCED_TIER_THRESHOLD ? 'Advanced' : 'Standard',
Tier: expiration || valueSizeBytes >= SSM_ADVANCED_TIER_THRESHOLD ? 'Advanced' : 'Standard',
Policies: expiration,
}),
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,47 @@ describe('scaleUp with GHES', () => {
});
});

it('adds an expiration policy to the JIT config when SSM_TOKEN_TTL_SECONDS is set', async () => {
process.env.SSM_TOKEN_TTL_SECONDS = '3600';
await scaleUpModule.scaleUp(TEST_DATA);
expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-12345',
Value: 'TEST_JIT_CONFIG_ORG',
Type: 'SecureString',
Tier: 'Advanced',
Policies: expect.stringContaining('"Type":"Expiration"') as unknown as string,
});
});

it('adds an expiration policy to the registration token when SSM_TOKEN_TTL_SECONDS is set', async () => {
process.env.ENABLE_EPHEMERAL_RUNNERS = 'false';
process.env.SSM_TOKEN_TTL_SECONDS = '3600';
await scaleUpModule.scaleUp(TEST_DATA);
expect(mockOctokit.actions.createRegistrationTokenForOrg).toBeCalled();
expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-12345',
Type: 'SecureString',
Tier: 'Advanced',
Policies: expect.stringContaining('"Type":"Expiration"') as unknown as string,
});
});

it('does not add an expiration policy when SSM_TOKEN_TTL_SECONDS is not set', async () => {
await scaleUpModule.scaleUp(TEST_DATA);
expect(mockSSMClient).toHaveReceivedNthSpecificCommandWith(1, PutParameterCommand, {
Name: '/github-action-runners/default/runners/config/i-12345',
Value: 'TEST_JIT_CONFIG_ORG',
Type: 'SecureString',
Tier: 'Standard',
Policies: undefined,
});
});

it('rejects an invalid SSM_TOKEN_TTL_SECONDS', async () => {
process.env.SSM_TOKEN_TTL_SECONDS = 'not-a-number';
await expect(scaleUpModule.scaleUp(TEST_DATA)).rejects.toThrow('SSM_TOKEN_TTL_SECONDS must be a positive number');
});

it('quotes runner labels with semicolon separators in non-ephemeral runner config', async () => {
process.env.ENABLE_EPHEMERAL_RUNNERS = 'false';
process.env.RUNNERS_MAXIMUM_COUNT = '2';
Expand Down
1 change: 1 addition & 0 deletions lambdas/libs/runner-providers/core/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ export interface CreateGitHubRunnerConfig {
runnerType: GitHubRunnerType;
disableAutoUpdate: boolean;
ssmTokenPath: string;
ssmTokenTtlSeconds?: number;
ssmConfigPath: string;
ssmParameterStoreTags: { Key: string; Value: string }[];
}
Expand Down
1 change: 1 addition & 0 deletions main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,7 @@ module "runners" {
tokens = "${var.ssm_paths.runners}/tokens"
config = "${var.ssm_paths.runners}/config"
}
ssm_token_ttl_seconds = var.ssm_token_ttl_seconds

s3_runner_binaries = var.enable_runner_binaries_syncer ? {
arn = module.runner_binaries[0].bucket.arn
Expand Down
1 change: 1 addition & 0 deletions modules/multi-runner/runners.tf
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ module "runners" {
tokens = "${var.ssm_paths.runners}/tokens"
config = "${var.ssm_paths.runners}/config"
}
ssm_token_ttl_seconds = each.value.runner_config.ssm_token_ttl_seconds

runner_os = each.value.runner_config.runner_os
instance_types = each.value.runner_config.instance_types
Expand Down
2 changes: 2 additions & 0 deletions modules/multi-runner/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,7 @@ variable "multi_runner_config" {
schedule_expression_timezone = optional(string)
size = number
})), [])
ssm_token_ttl_seconds = optional(number, null)
job_retry = optional(object({
enable = optional(bool, false)
delay_in_seconds = optional(number, 300)
Expand Down Expand Up @@ -279,6 +280,7 @@ variable "multi_runner_config" {
block_device_mappings: "The EC2 instance block device configuration. Takes the following keys: `device_name`, `delete_on_termination`, `volume_type`, `volume_size`, `encrypted`, `iops`, `throughput`, `kms_key_id`, `snapshot_id`, `volume_initialization_rate`."
job_retry: "Experimental! Can be removed / changed without trigger a major release. Configure job retries. The configuration enables job retries (for ephemeral runners). After creating the instances a message will be published to a job retry queue. The job retry check lambda is checking after a delay if the job is queued. If not the message will be published again on the scale-up (build queue). Using this feature can impact the rate limit of the GitHub app."
pool_config: "The configuration for updating the pool. The `pool_size` to adjust to by the events triggered by the `schedule_expression`. For example you can configure a cron expression for week days to adjust the pool to 10 and another expression for the weekend to adjust the pool to 1. Use `schedule_expression_timezone` to override the schedule time zone (defaults to UTC)."
ssm_token_ttl_seconds: "Optional TTL in seconds for the SSM parameters holding the runner registration token / JIT config. When set, the parameters are created with an SSM expiration policy so SSM deletes them itself after the TTL passes. Requires the Advanced parameter tier for every token parameter, which incurs additional costs. Expiration is enforced asynchronously by SSM; the SSM housekeeper lambda remains as a backstop. Must be a positive number, and should comfortably exceed the runner boot time so the config does not expire before the instance reads it."
iam_overrides: "Allows to (optionally) override the instance profile and runner role created by the module. Set `override_instance_profile` to true and provide the `instance_profile_name` to use an existing instance profile. Set `override_runner_role` to true and provide the `runner_role_arn` to use an existing role for the runner instances."
}
matcherConfig: {
Expand Down
1 change: 1 addition & 0 deletions modules/runners/pool.tf
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ module "pool" {
}
subnet_ids = var.subnet_ids
ssm_token_path = "${var.ssm_paths.root}/${var.ssm_paths.tokens}"
ssm_token_ttl_seconds = var.ssm_token_ttl_seconds
ssm_config_path = "${var.ssm_paths.root}/${var.ssm_paths.config}"
ami_id_ssm_parameter_name = local.ami_id_ssm_parameter_name
ami_id_ssm_parameter_read_policy_arn = local.ami_id_ssm_parameter_name != null ? aws_iam_policy.ami_id_ssm_parameter_read[0].arn : null
Expand Down
1 change: 1 addition & 0 deletions modules/runners/pool/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ resource "aws_lambda_function" "pool" {
RUNNER_OWNER = var.config.runner.pool_owner
RUNNERS_MAXIMUM_COUNT = var.config.runners_maximum_count
SSM_TOKEN_PATH = var.config.ssm_token_path
SSM_TOKEN_TTL_SECONDS = var.config.ssm_token_ttl_seconds != null ? var.config.ssm_token_ttl_seconds : ""
SSM_CONFIG_PATH = var.config.ssm_config_path
SUBNET_IDS = join(",", var.config.subnet_ids)
POWERTOOLS_SERVICE_NAME = "${var.config.prefix}-pool"
Expand Down
1 change: 1 addition & 0 deletions modules/runners/pool/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ variable "config" {
ami_id_ssm_parameter_arn = string
role_path = string
ssm_token_path = string
ssm_token_ttl_seconds = optional(number, null)
ssm_config_path = string
ami_id_ssm_parameter_name = string
ami_id_ssm_parameter_read_policy_arn = string
Expand Down
1 change: 1 addition & 0 deletions modules/runners/scale-up.tf
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ resource "aws_lambda_function" "scale_up" {
RUNNERS_MAXIMUM_COUNT = var.runners_maximum_count
POWERTOOLS_SERVICE_NAME = "${var.prefix}-scale-up"
SSM_TOKEN_PATH = local.token_path
SSM_TOKEN_TTL_SECONDS = var.ssm_token_ttl_seconds != null ? var.ssm_token_ttl_seconds : ""
SSM_CONFIG_PATH = "${var.ssm_paths.root}/${var.ssm_paths.config}"
SSM_PARAMETER_STORE_TAGS = local.parameter_store_tags
SUBNET_IDS = join(",", var.subnet_ids)
Expand Down
11 changes: 11 additions & 0 deletions modules/runners/variables.tf
Original file line number Diff line number Diff line change
Expand Up @@ -654,6 +654,17 @@ variable "ssm_paths" {
})
}

variable "ssm_token_ttl_seconds" {
description = "Optional TTL in seconds for the SSM parameters holding the runner registration token / JIT config. When set, the parameters are created with an SSM expiration policy so SSM deletes them itself after the TTL passes. Requires the Advanced parameter tier for every token parameter, which incurs additional costs. Expiration is enforced asynchronously by SSM; the SSM housekeeper lambda remains as a backstop. Must be a positive number, and should comfortably exceed the runner boot time so the config does not expire before the instance reads it."
type = number
default = null

validation {
condition = var.ssm_token_ttl_seconds == null ? true : var.ssm_token_ttl_seconds > 0
error_message = "`ssm_token_ttl_seconds` must be a positive number."
}
}

variable "runner_name_prefix" {
description = "The prefix used for the GitHub runner name. The prefix will be used in the default start script to prefix the instance name when register the runner in GitHub. The value is available via an EC2 tag 'ghr:runner_name_prefix'."
type = string
Expand Down
Loading
Loading