diff --git a/action.yml b/action.yml index cc862031..ba85d6e9 100644 --- a/action.yml +++ b/action.yml @@ -70,6 +70,15 @@ inputs: IAM Role Name to attach to the created EC2 instance. This requires additional permissions on the AWS role used to launch instances. required: false + debug: + description: >- + When 'true', the action emits extra diagnostic output to the + Actions run log: input parameters (secrets redacted), AWS SDK + response metadata, runner-registration poll details. Leave at + 'false' for normal operation. Set 'true' when troubleshooting + bootstrap failures. + required: false + default: 'false' aws-resource-tags: description: >- Tags to attach to the launched EC2 instance and volume. diff --git a/dist/index.js b/dist/index.js index 80f6fd5e..554818bb 100644 --- a/dist/index.js +++ b/dist/index.js @@ -87854,6 +87854,7 @@ const { } = __nccwpck_require__(5193); const core = __nccwpck_require__(7484); const config = __nccwpck_require__(1283); +const log = __nccwpck_require__(7223); const { sortByCreationDate } = __nccwpck_require__(5804); // EC2Client reads region + credentials from the environment (set by @@ -87865,13 +87866,17 @@ function ec2Client() { } async function waitForInstanceRunning(ec2InstanceId) { + const start = Date.now(); + log.info('wait_for_instance', { instance_id: ec2InstanceId }); try { await waitUntilInstanceRunning( { client: ec2Client(), maxWaitTime: 300 }, { InstanceIds: [ec2InstanceId] }, ); + log.info('wait_for_instance', { instance_id: ec2InstanceId, elapsed_ms: Date.now() - start }); core.info(`AWS EC2 instance ${ec2InstanceId} is up and running`); } catch (error) { + log.error('wait_for_instance', { instance_id: ec2InstanceId, error: error.name, message: error.message }); core.error(`AWS EC2 instance ${ec2InstanceId} initialization error`); throw error; } @@ -87892,12 +87897,17 @@ async function resolveImageId(client) { amiParams.Owners = [config.input.ec2ImageOwner]; } + log.info('describe_images', { owner: config.input.ec2ImageOwner || null, filters: config.input.ec2ImageFilters }); const result = await client.send(new DescribeImagesCommand(amiParams)); if (!result.Images || result.Images.length === 0) { + log.error('describe_images', { match_count: 0 }); throw new Error('Unable to find AMI using passed filter'); } sortByCreationDate(result); - return result.Images[0].ImageId; + const picked = result.Images[0].ImageId; + log.info('describe_images', { match_count: result.Images.length, selected_ami: picked }); + log.debug('describe_images_all', { images: result.Images.map(i => ({ id: i.ImageId, name: i.Name, created: i.CreationDate })) }); + return picked; } async function startEc2Instance(label, githubRegistrationToken) { @@ -87934,11 +87944,22 @@ async function startEc2Instance(label, githubRegistrationToken) { }; let ec2InstanceId; + const runStart = Date.now(); + log.info('run_instance', { + ami_id: config.input.ec2ImageId, + instance_type: config.input.ec2InstanceType, + subnet_id: config.input.subnetId, + sg_id: config.input.securityGroupId, + iam_role: config.input.iamRoleName || null, + label, + }); try { const result = await client.send(new RunInstancesCommand(params)); ec2InstanceId = result.Instances[0].InstanceId; + log.info('run_instance', { instance_id: ec2InstanceId, elapsed_ms: Date.now() - runStart }); core.info(`AWS EC2 instance ${ec2InstanceId} is started`); } catch (error) { + log.error('run_instance', { error: error.name, message: error.message }); core.error('AWS EC2 instance starting error'); throw error; } @@ -87947,11 +87968,13 @@ async function startEc2Instance(label, githubRegistrationToken) { await waitForInstanceRunning(ec2InstanceId); try { + log.info('associate_address', { allocation_id: config.input.eipAllocationId, instance_id: ec2InstanceId }); await client.send(new AssociateAddressCommand({ AllocationId: config.input.eipAllocationId, InstanceId: ec2InstanceId, })); } catch (error) { + log.warn('associate_address', { allocation_id: config.input.eipAllocationId, instance_id: ec2InstanceId, error: error.name, message: error.message }); core.warning(`Elastic IP association error, trying to proceed w/o EIP: ${error.message}`); } } @@ -87962,12 +87985,16 @@ async function startEc2Instance(label, githubRegistrationToken) { async function terminateEc2Instance() { const client = ec2Client(); + const start = Date.now(); + log.info('terminate_instance', { instance_id: config.input.ec2InstanceId }); try { await client.send(new TerminateInstancesCommand({ InstanceIds: [config.input.ec2InstanceId], })); + log.info('terminate_instance', { instance_id: config.input.ec2InstanceId, elapsed_ms: Date.now() - start }); core.info(`AWS EC2 instance ${config.input.ec2InstanceId} is terminated`); } catch (error) { + log.error('terminate_instance', { instance_id: config.input.ec2InstanceId, error: error.name, message: error.message }); core.error(`AWS EC2 instance ${config.input.ec2InstanceId} termination error`); throw error; } @@ -88003,6 +88030,7 @@ class Config { label: core.getInput('label'), ec2InstanceId: core.getInput('ec2-instance-id'), iamRoleName: core.getInput('iam-role-name'), + debug: core.getInput('debug') || 'false', }; const tags = JSON.parse(core.getInput('aws-resource-tags')); @@ -88069,6 +88097,7 @@ const core = __nccwpck_require__(7484); const github = __nccwpck_require__(3228); const _ = __nccwpck_require__(9975); const config = __nccwpck_require__(1283); +const log = __nccwpck_require__(7223); // use the unique label to find the runner // as we don't have the runner's id, it's not possible to get it in any other way @@ -88087,12 +88116,16 @@ async function getRunner(label) { // get GitHub Registration Token for registering a self-hosted runner async function getRegistrationToken() { const octokit = github.getOctokit(config.input.githubToken); + const start = Date.now(); + log.info('gh_registration_token', { ...config.githubContext }); try { const response = await octokit.request('POST /repos/{owner}/{repo}/actions/runners/registration-token', config.githubContext); + log.info('gh_registration_token', { ...config.githubContext, elapsed_ms: Date.now() - start }); core.info('GitHub Registration Token is received'); return response.data.token; } catch (error) { + log.error('gh_registration_token', { error: error.name, message: error.message, status: error.status }); core.error('GitHub Registration Token receiving error'); throw error; } @@ -88104,15 +88137,20 @@ async function removeRunner() { // skip the runner removal process if the runner is not found if (!runner) { + log.info('remove_runner', { label: config.input.label, skipped: true, reason: 'not_found' }); core.info(`GitHub self-hosted runner with label ${config.input.label} is not found, so the removal is skipped`); return; } + const start = Date.now(); + log.info('remove_runner', { runner_id: runner.id, label: config.input.label }); try { await octokit.request('DELETE /repos/{owner}/{repo}/actions/runners/{runner_id}', _.merge(config.githubContext, { runner_id: runner.id })); + log.info('remove_runner', { runner_id: runner.id, label: config.input.label, elapsed_ms: Date.now() - start }); core.info(`GitHub self-hosted runner ${runner.name} is removed`); return; } catch (error) { + log.error('remove_runner', { runner_id: runner.id, label: config.input.label, error: error.name, message: error.message }); core.error('GitHub self-hosted runner removal error'); throw error; } @@ -88131,14 +88169,17 @@ async function waitForRunnerRegistered(label) { return new Promise((resolve, reject) => { const interval = setInterval(async () => { const runner = await getRunner(label); + log.debug('wait_for_runner_poll', { label, elapsed_s: waitSeconds, found: !!runner, status: runner ? runner.status : null }); if (waitSeconds > timeoutMinutes * 60) { + log.error('wait_for_runner', { label, timeout_minutes: timeoutMinutes }); core.error('GitHub self-hosted runner registration error'); clearInterval(interval); reject(`A timeout of ${timeoutMinutes} minutes is exceeded. Your AWS EC2 instance was not able to register itself in GitHub as a new self-hosted runner.`); } if (runner && runner.status === 'online') { + log.info('wait_for_runner', { label, runner_id: runner.id, elapsed_s: waitSeconds }); core.info(`GitHub self-hosted runner ${runner.name} is registered and ready to use`); clearInterval(interval); resolve(); @@ -88157,6 +88198,105 @@ module.exports = { }; +/***/ }), + +/***/ 7223: +/***/ ((module, __unused_webpack_exports, __nccwpck_require__) => { + +const core = __nccwpck_require__(7484); + +// Structured logger. Every notable lifecycle event emits a JSON-shaped +// line via core.info so the Actions run summary can be scraped or +// eyeballed without parsing free-form text. When config.input.debug is +// true the debug() helper also emits, which gives consumers a way to +// get verbose diagnostics without changing default output. +// +// Gotchas: +// - We defer `require('./config')` until first use because log.js is +// loaded transitively from src/index.js before config validation +// completes; importing at top-level would short-circuit on any +// config error. +// - sanitize() redacts values under known secret keys. Always pass +// raw fields through sanitize() before logging an object that may +// contain user input (tokens, credentials, etc.). + +const SECRET_KEYS = new Set([ + 'githubToken', + 'github-token', + 'token', + 'aws-access-key-id', + 'aws-secret-access-key', + 'GPG_PRIVATE_KEY', + 'password', +]); + +function sanitize(fields) { + if (!fields || typeof fields !== 'object') return fields; + const out = Array.isArray(fields) ? [] : {}; + for (const [k, v] of Object.entries(fields)) { + if (SECRET_KEYS.has(k)) { + out[k] = '***'; + } else if (v && typeof v === 'object') { + out[k] = sanitize(v); + } else { + out[k] = v; + } + } + return out; +} + +function emit(level, step, fields) { + const payload = { + step, + mode: (() => { + // best-effort mode lookup; log.js may be required before Config + // finishes its constructor, in which case config.input is undefined. + try { + const config = __nccwpck_require__(1283); + return config && config.input ? config.input.mode : undefined; + } catch (_e) { + return undefined; + } + })(), + ...(fields ? sanitize(fields) : {}), + }; + const line = JSON.stringify(payload); + switch (level) { + case 'warning': + core.warning(line); + break; + case 'error': + core.error(line); + break; + default: + core.info(line); + } +} + +function info(step, fields) { emit('info', step, fields); } +function warn(step, fields) { emit('warning', step, fields); } +function err(step, fields) { emit('error', step, fields); } + +function debug(step, fields) { + try { + const config = __nccwpck_require__(1283); + if (config && config.input && config.input.debug === 'true') { + emit('info', step, { debug: true, ...fields }); + } + } catch (_e) { + // Config not yet loaded — skip debug output. + } +} + +module.exports = { + info, + warn, + error: err, + debug, + sanitize, +}; + + /***/ }), /***/ 5804: @@ -88639,6 +88779,7 @@ const os = __nccwpck_require__(857); const aws = __nccwpck_require__(3776); const gh = __nccwpck_require__(5934); const config = __nccwpck_require__(1283); +const log = __nccwpck_require__(7223); const core = __nccwpck_require__(7484); // Write directly to the $GITHUB_OUTPUT file. The bundled @actions/core @@ -88656,23 +88797,38 @@ function setOutput(label, ec2InstanceId) { } async function start() { - const label = config.generateUniqueLabel(); - const githubRegistrationToken = await gh.getRegistrationToken(); - const ec2InstanceId = await aws.startEc2Instance(label, githubRegistrationToken); - setOutput(label, ec2InstanceId); - await aws.waitForInstanceRunning(ec2InstanceId); - await gh.waitForRunnerRegistered(label); + core.startGroup('start-runner'); + try { + log.debug('start_inputs', config.input); // sanitized inside log.js + const label = config.generateUniqueLabel(); + const githubRegistrationToken = await gh.getRegistrationToken(); + const ec2InstanceId = await aws.startEc2Instance(label, githubRegistrationToken); + setOutput(label, ec2InstanceId); + await aws.waitForInstanceRunning(ec2InstanceId); + await gh.waitForRunnerRegistered(label); + log.info('start', { label, instance_id: ec2InstanceId, outcome: 'registered' }); + } finally { + core.endGroup(); + } } async function stop() { - await aws.terminateEc2Instance(); - await gh.removeRunner(); + core.startGroup('stop-runner'); + try { + log.debug('stop_inputs', config.input); + await aws.terminateEc2Instance(); + await gh.removeRunner(); + log.info('stop', { instance_id: config.input.ec2InstanceId, label: config.input.label, outcome: 'ok' }); + } finally { + core.endGroup(); + } } (async function () { try { config.input.mode === 'start' ? await start() : await stop(); } catch (error) { + log.error('fatal', { mode: config.input.mode, error: error.name, message: error.message }); core.error(error); core.setFailed(error.message); } diff --git a/src/aws.js b/src/aws.js index 0334d312..7c3dfc57 100644 --- a/src/aws.js +++ b/src/aws.js @@ -8,6 +8,7 @@ const { } = require('@aws-sdk/client-ec2'); const core = require('@actions/core'); const config = require('./config'); +const log = require('./log'); const { sortByCreationDate } = require('./utils'); // EC2Client reads region + credentials from the environment (set by @@ -19,13 +20,17 @@ function ec2Client() { } async function waitForInstanceRunning(ec2InstanceId) { + const start = Date.now(); + log.info('wait_for_instance', { instance_id: ec2InstanceId }); try { await waitUntilInstanceRunning( { client: ec2Client(), maxWaitTime: 300 }, { InstanceIds: [ec2InstanceId] }, ); + log.info('wait_for_instance', { instance_id: ec2InstanceId, elapsed_ms: Date.now() - start }); core.info(`AWS EC2 instance ${ec2InstanceId} is up and running`); } catch (error) { + log.error('wait_for_instance', { instance_id: ec2InstanceId, error: error.name, message: error.message }); core.error(`AWS EC2 instance ${ec2InstanceId} initialization error`); throw error; } @@ -46,12 +51,17 @@ async function resolveImageId(client) { amiParams.Owners = [config.input.ec2ImageOwner]; } + log.info('describe_images', { owner: config.input.ec2ImageOwner || null, filters: config.input.ec2ImageFilters }); const result = await client.send(new DescribeImagesCommand(amiParams)); if (!result.Images || result.Images.length === 0) { + log.error('describe_images', { match_count: 0 }); throw new Error('Unable to find AMI using passed filter'); } sortByCreationDate(result); - return result.Images[0].ImageId; + const picked = result.Images[0].ImageId; + log.info('describe_images', { match_count: result.Images.length, selected_ami: picked }); + log.debug('describe_images_all', { images: result.Images.map(i => ({ id: i.ImageId, name: i.Name, created: i.CreationDate })) }); + return picked; } async function startEc2Instance(label, githubRegistrationToken) { @@ -88,11 +98,22 @@ async function startEc2Instance(label, githubRegistrationToken) { }; let ec2InstanceId; + const runStart = Date.now(); + log.info('run_instance', { + ami_id: config.input.ec2ImageId, + instance_type: config.input.ec2InstanceType, + subnet_id: config.input.subnetId, + sg_id: config.input.securityGroupId, + iam_role: config.input.iamRoleName || null, + label, + }); try { const result = await client.send(new RunInstancesCommand(params)); ec2InstanceId = result.Instances[0].InstanceId; + log.info('run_instance', { instance_id: ec2InstanceId, elapsed_ms: Date.now() - runStart }); core.info(`AWS EC2 instance ${ec2InstanceId} is started`); } catch (error) { + log.error('run_instance', { error: error.name, message: error.message }); core.error('AWS EC2 instance starting error'); throw error; } @@ -101,11 +122,13 @@ async function startEc2Instance(label, githubRegistrationToken) { await waitForInstanceRunning(ec2InstanceId); try { + log.info('associate_address', { allocation_id: config.input.eipAllocationId, instance_id: ec2InstanceId }); await client.send(new AssociateAddressCommand({ AllocationId: config.input.eipAllocationId, InstanceId: ec2InstanceId, })); } catch (error) { + log.warn('associate_address', { allocation_id: config.input.eipAllocationId, instance_id: ec2InstanceId, error: error.name, message: error.message }); core.warning(`Elastic IP association error, trying to proceed w/o EIP: ${error.message}`); } } @@ -116,12 +139,16 @@ async function startEc2Instance(label, githubRegistrationToken) { async function terminateEc2Instance() { const client = ec2Client(); + const start = Date.now(); + log.info('terminate_instance', { instance_id: config.input.ec2InstanceId }); try { await client.send(new TerminateInstancesCommand({ InstanceIds: [config.input.ec2InstanceId], })); + log.info('terminate_instance', { instance_id: config.input.ec2InstanceId, elapsed_ms: Date.now() - start }); core.info(`AWS EC2 instance ${config.input.ec2InstanceId} is terminated`); } catch (error) { + log.error('terminate_instance', { instance_id: config.input.ec2InstanceId, error: error.name, message: error.message }); core.error(`AWS EC2 instance ${config.input.ec2InstanceId} termination error`); throw error; } diff --git a/src/config.js b/src/config.js index bfd2696f..d9cd5192 100644 --- a/src/config.js +++ b/src/config.js @@ -16,6 +16,7 @@ class Config { label: core.getInput('label'), ec2InstanceId: core.getInput('ec2-instance-id'), iamRoleName: core.getInput('iam-role-name'), + debug: core.getInput('debug') || 'false', }; const tags = JSON.parse(core.getInput('aws-resource-tags')); diff --git a/src/gh.js b/src/gh.js index abf9af94..a5a0c4b7 100644 --- a/src/gh.js +++ b/src/gh.js @@ -2,6 +2,7 @@ const core = require('@actions/core'); const github = require('@actions/github'); const _ = require('lodash'); const config = require('./config'); +const log = require('./log'); // use the unique label to find the runner // as we don't have the runner's id, it's not possible to get it in any other way @@ -20,12 +21,16 @@ async function getRunner(label) { // get GitHub Registration Token for registering a self-hosted runner async function getRegistrationToken() { const octokit = github.getOctokit(config.input.githubToken); + const start = Date.now(); + log.info('gh_registration_token', { ...config.githubContext }); try { const response = await octokit.request('POST /repos/{owner}/{repo}/actions/runners/registration-token', config.githubContext); + log.info('gh_registration_token', { ...config.githubContext, elapsed_ms: Date.now() - start }); core.info('GitHub Registration Token is received'); return response.data.token; } catch (error) { + log.error('gh_registration_token', { error: error.name, message: error.message, status: error.status }); core.error('GitHub Registration Token receiving error'); throw error; } @@ -37,15 +42,20 @@ async function removeRunner() { // skip the runner removal process if the runner is not found if (!runner) { + log.info('remove_runner', { label: config.input.label, skipped: true, reason: 'not_found' }); core.info(`GitHub self-hosted runner with label ${config.input.label} is not found, so the removal is skipped`); return; } + const start = Date.now(); + log.info('remove_runner', { runner_id: runner.id, label: config.input.label }); try { await octokit.request('DELETE /repos/{owner}/{repo}/actions/runners/{runner_id}', _.merge(config.githubContext, { runner_id: runner.id })); + log.info('remove_runner', { runner_id: runner.id, label: config.input.label, elapsed_ms: Date.now() - start }); core.info(`GitHub self-hosted runner ${runner.name} is removed`); return; } catch (error) { + log.error('remove_runner', { runner_id: runner.id, label: config.input.label, error: error.name, message: error.message }); core.error('GitHub self-hosted runner removal error'); throw error; } @@ -64,14 +74,17 @@ async function waitForRunnerRegistered(label) { return new Promise((resolve, reject) => { const interval = setInterval(async () => { const runner = await getRunner(label); + log.debug('wait_for_runner_poll', { label, elapsed_s: waitSeconds, found: !!runner, status: runner ? runner.status : null }); if (waitSeconds > timeoutMinutes * 60) { + log.error('wait_for_runner', { label, timeout_minutes: timeoutMinutes }); core.error('GitHub self-hosted runner registration error'); clearInterval(interval); reject(`A timeout of ${timeoutMinutes} minutes is exceeded. Your AWS EC2 instance was not able to register itself in GitHub as a new self-hosted runner.`); } if (runner && runner.status === 'online') { + log.info('wait_for_runner', { label, runner_id: runner.id, elapsed_s: waitSeconds }); core.info(`GitHub self-hosted runner ${runner.name} is registered and ready to use`); clearInterval(interval); resolve(); diff --git a/src/index.js b/src/index.js index 7980bfed..3e0dc8a1 100644 --- a/src/index.js +++ b/src/index.js @@ -18,6 +18,7 @@ const os = require('os'); const aws = require('./aws'); const gh = require('./gh'); const config = require('./config'); +const log = require('./log'); const core = require('@actions/core'); // Write directly to the $GITHUB_OUTPUT file. The bundled @actions/core @@ -35,23 +36,38 @@ function setOutput(label, ec2InstanceId) { } async function start() { - const label = config.generateUniqueLabel(); - const githubRegistrationToken = await gh.getRegistrationToken(); - const ec2InstanceId = await aws.startEc2Instance(label, githubRegistrationToken); - setOutput(label, ec2InstanceId); - await aws.waitForInstanceRunning(ec2InstanceId); - await gh.waitForRunnerRegistered(label); + core.startGroup('start-runner'); + try { + log.debug('start_inputs', config.input); // sanitized inside log.js + const label = config.generateUniqueLabel(); + const githubRegistrationToken = await gh.getRegistrationToken(); + const ec2InstanceId = await aws.startEc2Instance(label, githubRegistrationToken); + setOutput(label, ec2InstanceId); + await aws.waitForInstanceRunning(ec2InstanceId); + await gh.waitForRunnerRegistered(label); + log.info('start', { label, instance_id: ec2InstanceId, outcome: 'registered' }); + } finally { + core.endGroup(); + } } async function stop() { - await aws.terminateEc2Instance(); - await gh.removeRunner(); + core.startGroup('stop-runner'); + try { + log.debug('stop_inputs', config.input); + await aws.terminateEc2Instance(); + await gh.removeRunner(); + log.info('stop', { instance_id: config.input.ec2InstanceId, label: config.input.label, outcome: 'ok' }); + } finally { + core.endGroup(); + } } (async function () { try { config.input.mode === 'start' ? await start() : await stop(); } catch (error) { + log.error('fatal', { mode: config.input.mode, error: error.name, message: error.message }); core.error(error); core.setFailed(error.message); } diff --git a/src/log.js b/src/log.js new file mode 100644 index 00000000..5194c2d6 --- /dev/null +++ b/src/log.js @@ -0,0 +1,92 @@ +const core = require('@actions/core'); + +// Structured logger. Every notable lifecycle event emits a JSON-shaped +// line via core.info so the Actions run summary can be scraped or +// eyeballed without parsing free-form text. When config.input.debug is +// true the debug() helper also emits, which gives consumers a way to +// get verbose diagnostics without changing default output. +// +// Gotchas: +// - We defer `require('./config')` until first use because log.js is +// loaded transitively from src/index.js before config validation +// completes; importing at top-level would short-circuit on any +// config error. +// - sanitize() redacts values under known secret keys. Always pass +// raw fields through sanitize() before logging an object that may +// contain user input (tokens, credentials, etc.). + +const SECRET_KEYS = new Set([ + 'githubToken', + 'github-token', + 'token', + 'aws-access-key-id', + 'aws-secret-access-key', + 'GPG_PRIVATE_KEY', + 'password', +]); + +function sanitize(fields) { + if (!fields || typeof fields !== 'object') return fields; + const out = Array.isArray(fields) ? [] : {}; + for (const [k, v] of Object.entries(fields)) { + if (SECRET_KEYS.has(k)) { + out[k] = '***'; + } else if (v && typeof v === 'object') { + out[k] = sanitize(v); + } else { + out[k] = v; + } + } + return out; +} + +function emit(level, step, fields) { + const payload = { + step, + mode: (() => { + // best-effort mode lookup; log.js may be required before Config + // finishes its constructor, in which case config.input is undefined. + try { + const config = require('./config'); + return config && config.input ? config.input.mode : undefined; + } catch (_e) { + return undefined; + } + })(), + ...(fields ? sanitize(fields) : {}), + }; + const line = JSON.stringify(payload); + switch (level) { + case 'warning': + core.warning(line); + break; + case 'error': + core.error(line); + break; + default: + core.info(line); + } +} + +function info(step, fields) { emit('info', step, fields); } +function warn(step, fields) { emit('warning', step, fields); } +function err(step, fields) { emit('error', step, fields); } + +function debug(step, fields) { + try { + const config = require('./config'); + if (config && config.input && config.input.debug === 'true') { + emit('info', step, { debug: true, ...fields }); + } + } catch (_e) { + // Config not yet loaded — skip debug output. + } +} + +module.exports = { + info, + warn, + error: err, + debug, + sanitize, +}; diff --git a/tests/config.test.js b/tests/config.test.js index 6eb10415..65a9ab5b 100644 --- a/tests/config.test.js +++ b/tests/config.test.js @@ -131,6 +131,18 @@ describe('Config — mode validation', () => { }); }); +describe('Config — debug input', () => { + test('defaults to "false" when unset', () => { + const config = loadConfig(startModeInputs); + expect(config.input.debug).toBe('false'); + }); + + test('honors "true"', () => { + const config = loadConfig({ ...startModeInputs, 'debug': 'true' }); + expect(config.input.debug).toBe('true'); + }); +}); + describe('Config — generateUniqueLabel', () => { test('returns a 5-character alphanumeric string', () => { const config = loadConfig(startModeInputs); diff --git a/tests/log.test.js b/tests/log.test.js new file mode 100644 index 00000000..e9457b91 --- /dev/null +++ b/tests/log.test.js @@ -0,0 +1,87 @@ +// log.js emits structured JSON via @actions/core.info/warning/error. +// Tests stub the core module and observe what the logger passes through. + +const coreMock = { + info: jest.fn(), + warning: jest.fn(), + error: jest.fn(), +}; + +beforeEach(() => { + jest.resetModules(); + coreMock.info.mockReset(); + coreMock.warning.mockReset(); + coreMock.error.mockReset(); + jest.doMock('@actions/core', () => coreMock); + // config is imported lazily inside log.js; stub it so mode is "start" + // and debug is false by default. + jest.doMock('./src/config', () => ({ input: { mode: 'start', debug: 'false' } }), { virtual: true }); +}); + +function loadLog({ debug = 'false', mode = 'start' } = {}) { + jest.resetModules(); + jest.doMock('@actions/core', () => coreMock); + jest.doMock('../src/config', () => ({ input: { mode, debug } }), { virtual: false }); + return require('../src/log'); +} + +describe('log', () => { + test('info emits JSON with step + mode', () => { + const log = loadLog(); + log.info('run_instance', { instance_id: 'i-abc' }); + expect(coreMock.info).toHaveBeenCalledTimes(1); + const payload = JSON.parse(coreMock.info.mock.calls[0][0]); + expect(payload).toEqual({ step: 'run_instance', mode: 'start', instance_id: 'i-abc' }); + }); + + test('warn routes through core.warning', () => { + const log = loadLog(); + log.warn('associate_address', { error: 'Boom' }); + expect(coreMock.warning).toHaveBeenCalledTimes(1); + expect(JSON.parse(coreMock.warning.mock.calls[0][0])).toMatchObject({ step: 'associate_address', error: 'Boom' }); + }); + + test('error routes through core.error', () => { + const log = loadLog(); + log.error('terminate_instance', { error: 'Nope' }); + expect(coreMock.error).toHaveBeenCalledTimes(1); + expect(JSON.parse(coreMock.error.mock.calls[0][0])).toMatchObject({ step: 'terminate_instance', error: 'Nope' }); + }); + + test('debug emits nothing when config.input.debug is not "true"', () => { + const log = loadLog({ debug: 'false' }); + log.debug('describe_images_all', { images: [1, 2, 3] }); + expect(coreMock.info).not.toHaveBeenCalled(); + }); + + test('debug emits JSON when config.input.debug is "true"', () => { + const log = loadLog({ debug: 'true' }); + log.debug('describe_images_all', { images: [{ id: 'ami-1' }] }); + expect(coreMock.info).toHaveBeenCalledTimes(1); + const payload = JSON.parse(coreMock.info.mock.calls[0][0]); + expect(payload).toMatchObject({ step: 'describe_images_all', debug: true }); + expect(payload.images).toEqual([{ id: 'ami-1' }]); + }); + + test('sanitize redacts known secret keys', () => { + const log = loadLog(); + const out = log.sanitize({ + githubToken: 'ghs_abc', + label: 'runner-xyz', + nested: { 'github-token': 'ghs_inner', password: 'p', other: 'ok' }, + }); + expect(out).toEqual({ + githubToken: '***', + label: 'runner-xyz', + nested: { 'github-token': '***', password: '***', other: 'ok' }, + }); + }); + + test('info with a payload containing secret keys redacts them', () => { + const log = loadLog(); + log.info('start_inputs', { githubToken: 'ghs_abc', label: 'runner-xyz' }); + const payload = JSON.parse(coreMock.info.mock.calls[0][0]); + expect(payload.githubToken).toBe('***'); + expect(payload.label).toBe('runner-xyz'); + }); +});