diff --git a/dist/index.js b/dist/index.js index d5b66f92..50786e5b 100644 --- a/dist/index.js +++ b/dist/index.js @@ -105809,7 +105809,18 @@ async function warmStartInstance(instanceId, { userData, label }) { const client = ec2Client(); await client.send(new ModifyInstanceAttributeCommand({ InstanceId: instanceId, - UserData: { Value: Buffer.from(userData).toString('base64') }, + // ModifyInstanceAttribute's UserData is a BLOB (BlobAttributeValue.Value: + // Uint8Array), NOT the plain string RunInstances takes. The SDK + // base64-encodes the blob's bytes for the EC2 query wire, so hand it the + // RAW user-data bytes. Pre-encoding to a base64 STRING here double-encodes + // it: the wire carries base64(base64(userData)), EC2 decodes that once and + // IMDS then serves the base64 TEXT instead of the script. The warm-restart + // register step reads that via IMDS, its `sed` finds no GH_REPO_URL='...' + // line, and config.sh aborts with "Invalid configuration provided for url" + // — every reuse: stop warm start failed registration this way. Cold + // launches were unaffected: RunInstances (startEc2Instance) correctly + // pre-encodes because its UserData field is a string, not a blob. + UserData: { Value: Buffer.from(userData) }, })); await client.send(new CreateTagsCommand({ Resources: [instanceId], diff --git a/src/aws.js b/src/aws.js index d6bb414f..854f6164 100644 --- a/src/aws.js +++ b/src/aws.js @@ -1159,7 +1159,18 @@ async function warmStartInstance(instanceId, { userData, label }) { const client = ec2Client(); await client.send(new ModifyInstanceAttributeCommand({ InstanceId: instanceId, - UserData: { Value: Buffer.from(userData).toString('base64') }, + // ModifyInstanceAttribute's UserData is a BLOB (BlobAttributeValue.Value: + // Uint8Array), NOT the plain string RunInstances takes. The SDK + // base64-encodes the blob's bytes for the EC2 query wire, so hand it the + // RAW user-data bytes. Pre-encoding to a base64 STRING here double-encodes + // it: the wire carries base64(base64(userData)), EC2 decodes that once and + // IMDS then serves the base64 TEXT instead of the script. The warm-restart + // register step reads that via IMDS, its `sed` finds no GH_REPO_URL='...' + // line, and config.sh aborts with "Invalid configuration provided for url" + // — every reuse: stop warm start failed registration this way. Cold + // launches were unaffected: RunInstances (startEc2Instance) correctly + // pre-encodes because its UserData field is a string, not a blob. + UserData: { Value: Buffer.from(userData) }, })); await client.send(new CreateTagsCommand({ Resources: [instanceId], diff --git a/tests/warmpool-userdata-encoding.test.js b/tests/warmpool-userdata-encoding.test.js new file mode 100644 index 00000000..5a46af51 --- /dev/null +++ b/tests/warmpool-userdata-encoding.test.js @@ -0,0 +1,66 @@ +// Serialization regression for the warm-pool (reuse: stop) user-data rewrite. +// +// warmStartInstance rewrites a stopped instance's user-data via +// ModifyInstanceAttribute before restarting it. That field is a BLOB +// (BlobAttributeValue.Value: Uint8Array), unlike RunInstances' plain-string +// UserData. The EC2 query protocol base64-encodes a blob's bytes on the wire, +// and EC2 base64-decodes them once before storing what IMDS serves. So the +// value handed to the SDK must be the RAW user-data bytes: a pre-base64'd +// string double-encodes (wire carries base64(base64(userData))), EC2 decodes +// only the outer layer, and IMDS ends up serving the base64 TEXT instead of +// the bootstrap script. The per-boot register step then reads an empty +// GH_REPO_URL and config.sh aborts with "Invalid configuration provided for +// url" — the failure this test guards against. +// +// Unlike warmpool.test.js this file does NOT mock @aws-sdk/client-ec2: it runs +// a real EC2Client through a capturing requestHandler so the assertion pins +// the SDK's actual wire behavior, not a re-implementation of it. +const { EC2Client, ModifyInstanceAttributeCommand } = require('@aws-sdk/client-ec2'); + +// Serialize a ModifyInstanceAttribute request and return what EC2 would store +// as user-data (i.e. IMDS-served bytes) = the wire UserData.Value blob decoded +// once from base64. +async function imdsWouldServe(value) { + let body = null; + const client = new EC2Client({ + region: 'us-east-1', + credentials: { accessKeyId: 'AKIATEST', secretAccessKey: 'secret' }, + requestHandler: { + handle(req) { + body = req.body; + // Abort before any network I/O; we only need the serialized body. + return Promise.reject(new Error('__captured__')); + }, + }, + }); + try { + await client.send(new ModifyInstanceAttributeCommand({ + InstanceId: 'i-abc', UserData: { Value: value }, + })); + } catch (err) { + if (err.message !== '__captured__') throw err; + } + const match = /(?:^|&)UserData\.Value=([^&]*)/.exec(body || ''); + if (!match) throw new Error(`UserData.Value not found in serialized body: ${body}`); + const wire = decodeURIComponent(match[1]); + return Buffer.from(wire, 'base64').toString('utf8'); +} + +describe('ModifyInstanceAttribute user-data blob encoding', () => { + const userData = "#!/bin/bash\nGH_REPO_URL='https://github.com/o/r'\nGH_TOKEN='TOK'\n"; + + test('raw user-data bytes (the fix) survive the round-trip verbatim', async () => { + // This is exactly what warmStartInstance passes: Buffer.from(userData). + await expect(imdsWouldServe(Buffer.from(userData))).resolves.toBe(userData); + }); + + test("a pre-base64'd string (the bug) double-encodes and loses the script", async () => { + const served = await imdsWouldServe(Buffer.from(userData).toString('base64')); + // IMDS would serve base64 text, not the script — no GH_REPO_URL line, so + // the warm-restart register step reads an empty URL. + expect(served).not.toBe(userData); + expect(served).not.toMatch(/^GH_REPO_URL=/m); + // It's the singly-encoded base64 of the script (the double-encode victim). + expect(served).toBe(Buffer.from(userData).toString('base64')); + }); +}); diff --git a/tests/warmpool.test.js b/tests/warmpool.test.js index ade0fb70..787e5b02 100644 --- a/tests/warmpool.test.js +++ b/tests/warmpool.test.js @@ -87,7 +87,14 @@ describe('warmStartInstance', () => { expect(commandsSent()).toEqual(['ModifyInstanceAttribute', 'CreateTags', 'StartInstances']); const modify = mockSend.mock.calls[0][0]; expect(modify.InstanceId).toBe('i-1'); - expect(Buffer.from(modify.UserData.Value, 'base64').toString('utf8')).toContain('#!/bin/bash'); + // UserData.Value must be the RAW user-data bytes, not a pre-base64'd + // string: it's a blob the SDK base64-encodes on the wire, so a base64 + // string here double-encodes and IMDS serves base64 text (see + // warmStartInstance + the #66 serialization regression test). Assert the + // bytes decode straight back to the script — this fails against the old + // `.toString('base64')` value (a base64 string, not a Buffer/Uint8Array). + expect(Buffer.isBuffer(modify.UserData.Value) || modify.UserData.Value instanceof Uint8Array).toBe(true); + expect(Buffer.from(modify.UserData.Value).toString('utf8')).toBe('#!/bin/bash\ntrue'); const tags = mockSend.mock.calls[1][0].Tags.map((t) => t.Key); expect(tags).toContain(aws.LABEL_TAG_KEY); });