diff --git a/packages/serverless-offline-sqs/README.md b/packages/serverless-offline-sqs/README.md index 8fdc636..16276af 100644 --- a/packages/serverless-offline-sqs/README.md +++ b/packages/serverless-offline-sqs/README.md @@ -101,6 +101,50 @@ Start it with `docker compose up elasticmq`, then run `serverless offline` on th `http://localhost:9324` (see the [SQS](#sqs) config below). If you'd rather not write a config file, the bare `docker run -p 9324:9324 -p 9325:9325 softwaremill/elasticmq-native` works too. +### Zero-setup local SQS with `autoStart` (#146) + +If you don't want to manage the container yourself, set `autoStart: true` and the plugin starts an +ElasticMQ container for you during `serverless offline`, points the SQS client at it, and tears it +down when the session ends (or on `Ctrl-C`). Combined with `autoCreate: true`, every declared/event +queue is created inside that container before the listeners attach — so `npm install` plus the +config below is all you need, **nothing else to run**: + +```yml +custom: + serverless-offline-sqs: + autoStart: true # spawn + manage an ElasticMQ container for this session + autoCreate: true # create the declared/event queues inside it + region: eu-west-1 + accountId: '000000000000' +``` + +> **Self-contained _given Docker_, not zero-runtime.** `autoStart` runs the real +> `softwaremill/elasticmq-native` engine in a container (the same engine the manual setup above uses, +> so FIFO/DLQ behaviour is identical), driven through the Docker **CLI** — so **Docker must be +> installed and running**. If it isn't, the plugin fails fast with a clear error and you can fall back +> to the manual docker-compose setup above. autoStart does not bundle a JVM or a pure-JS emulator. + +**Precedence & defaults.** `autoStart` is **opt-in** — unset, the plugin behaves exactly as before. +If you also set an explicit `endpoint`, `autoStart` is skipped in favour of your endpoint (with a +warning). When `autoStart` starts a container and you configured no credentials, harmless local +placeholders are injected (ElasticMQ ignores credential values). Tune the container with the object +form: + +```yml +custom: + serverless-offline-sqs: + autoStart: + image: softwaremill/elasticmq-native:1.6.11 # default (pinned, not :latest) + port: 9324 # host port -> container 9324 (default 9324) + pullPolicy: missing # 'missing' (default) pulls only if absent; 'always' always pulls + readinessTimeout: 30000 # ms to wait for the port to answer before giving up (default 30000) + autoCreate: true +``` + +A leaked container from a hard crash (`SIGKILL`) is reclaimed automatically on the next start: the +container runs `--rm` under a fixed `--name`, and the next `autoStart` force-removes any pre-existing +container of that name before re-creating it. + ### Dead-letter queues & redrive (`#167`, `#133`, `#65`, `#87`) When `autoCreate: true`, the plugin now creates **every** `AWS::SQS::Queue` declared in diff --git a/packages/serverless-offline-sqs/package.json b/packages/serverless-offline-sqs/package.json index e70e173..31c357f 100644 --- a/packages/serverless-offline-sqs/package.json +++ b/packages/serverless-offline-sqs/package.json @@ -15,6 +15,7 @@ "src/index.js", "src/log.js", "src/client-config.js", + "src/elasticmq.js", "src/sqs.js", "src/sqs-event.js", "src/sqs-event-definition.js" diff --git a/packages/serverless-offline-sqs/src/elasticmq.js b/packages/serverless-offline-sqs/src/elasticmq.js new file mode 100644 index 0000000..57336ba --- /dev/null +++ b/packages/serverless-offline-sqs/src/elasticmq.js @@ -0,0 +1,200 @@ +const {execFile: execFileCb} = require('child_process'); +const {promisify} = require('util'); +const {get, isNil, isPlainObject} = require('lodash/fp'); + +const {normalizeLog} = require('./log'); + +// #146 (tstackhouse, tristan-mastrodicasa): opt-in `autoStart` — spawn an ElasticMQ container from +// the plugin's own lifecycle so `autoStart: true` + `autoCreate: true` is a zero-setup local SQS. +// ALL Docker side effects live here; the plugin class only orchestrates. The Docker CLI is driven +// through child_process.execFile (argv array, no shell) so we add NO new runtime dependency, and +// readiness is probed with the built-in fetch (Node 18+). Both are injectable (see `deps`) so the +// whole lifecycle is unit-testable with mocks and never touches real Docker. + +// Pin a concrete image tag (NOT :latest) so a local/CI autoStart is reproducible; `pullPolicy: +// missing` caches the pull. The exact engine the repo's test suite + docker-compose already use. +const DEFAULT_AUTOSTART_IMAGE = 'softwaremill/elasticmq-native:1.6.11'; +// ElasticMQ's SQS REST API port, matching docker-compose.yml. +const DEFAULT_AUTOSTART_PORT = 9324; +// The container's fixed internal SQS port; only the host port is configurable. +const CONTAINER_PORT = 9324; +const DEFAULT_PULL_POLICY = 'missing'; +// Generous default: a cold ElasticMQ container is ready well under 30s. +const DEFAULT_READINESS_TIMEOUT = 30000; +// A fixed name so a leaked container (hard crash, SIGKILL) is reclaimed on the next start — the real +// safety net behind `--rm`, not the SIGINT handler. +const DEFAULT_CONTAINER_NAME = 'serverless-offline-sqs-autostart'; +const READINESS_POLL_INTERVAL = 250; + +// #146/#222: mirror isPluginEnabled's string coercion. autoStart is opt-in, so an absent value is +// OFF (the inverse default of `enabled`). The string 'false' (YAML/CLI) is off; any other truthy +// value — including the object config form `{port: ...}` — is on. Pure + non-throwing. +const isAutoStartEnabled = options => { + const autoStart = get('autoStart', options); + if (isNil(autoStart)) return false; + if (autoStart === 'false') return false; + return Boolean(autoStart); +}; + +const delay = timeout => + new Promise(resolve => { + setTimeout(resolve, timeout); + }); + +// resolveAutoStartOptions(options) -> {image, port, pullPolicy, readinessTimeout, name} +// Pure + non-mutating. `autoStart` may be `true`/`'true'` (all defaults) or an object of overrides +// (#146 AC9). A non-object autoStart contributes no overrides, so the pinned defaults stand. +const resolveAutoStartOptions = options => { + const autoStart = get('autoStart', options); + const config = isPlainObject(autoStart) ? autoStart : {}; + return { + image: isNil(config.image) ? DEFAULT_AUTOSTART_IMAGE : config.image, + port: isNil(config.port) ? DEFAULT_AUTOSTART_PORT : config.port, + pullPolicy: isNil(config.pullPolicy) ? DEFAULT_PULL_POLICY : config.pullPolicy, + readinessTimeout: isNil(config.readinessTimeout) + ? DEFAULT_READINESS_TIMEOUT + : config.readinessTimeout, + name: isNil(config.name) ? DEFAULT_CONTAINER_NAME : config.name + }; +}; + +// buildContainerArgs(opts) -> the `docker run` argv. Detached, self-removing (`--rm`), fixed +// `--name` (idempotent reclaim) and the `-Dnode-address.host="*"` flag the docker-compose service +// uses so the container answers on the host endpoint. Pure (#146 AC2/AC9). +const buildContainerArgs = ({name, port, image}) => [ + 'run', + '-d', + '--rm', + '--name', + name, + '-p', + `${port}:${CONTAINER_PORT}`, + image, + '-Dnode-address.host=*' +]; + +// The host endpoint the SQS client is pointed at; also the readiness probe target (#146 AC2). +const buildReadinessUrl = ({port}) => `http://localhost:${port}`; + +// #146: to be truly zero-setup, autoStart must not require the user to also configure credentials. +// ElasticMQ ignores credential VALUES, but the @aws-sdk v3 client still needs SOME credentials to +// sign — with none, buildCredentials returns undefined and the default provider chain hangs/throws +// ("Could not load credentials from any providers"). Inject harmless local placeholders, but ONLY +// when the user supplied neither key (an explicit pair still wins). Pure + non-mutating: returns a +// new options object. +const LOCAL_CREDENTIAL = 'localAutoStart'; +const ensureLocalCredentials = options => { + const accessKeyId = get('accessKeyId', options); + const secretAccessKey = get('secretAccessKey', options); + if (!isNil(accessKeyId) || !isNil(secretAccessKey)) return options; + return {...options, accessKeyId: LOCAL_CREDENTIAL, secretAccessKey: LOCAL_CREDENTIAL}; +}; + +// Build the error thrown when `docker version` fails — names Docker and points at the documented +// manual docker-compose fallback so the message is actionable (#146 AC6). +const dockerUnavailableError = cause => + new Error( + 'serverless-offline-sqs autoStart requires Docker, but `docker version` failed ' + + `(${cause && cause.message ? cause.message : cause}). Start Docker, or run an ElasticMQ ` + + 'container yourself and set custom.serverless-offline-sqs.endpoint (see the README).' + ); + +// startElasticMq(opts, log, deps?) -> Promise<{endpoint, stop()}> +// `opts` is the output of resolveAutoStartOptions. `deps` injects execFile/fetch/delay for tests. +// Sequence: preflight `docker version` (fail fast, AC6) -> ensure the image per pullPolicy -> +// reclaim a leaked `` (`rm -f`, idempotent) -> `docker run` -> poll readiness until the port +// answers or readinessTimeout (then tear down + throw, AC7). The returned `stop()` is +// guarded-idempotent so SIGINT-then-offline:start:end does not double-remove (AC4). +const startElasticMq = async (opts, log, deps = {}) => { + const logger = normalizeLog(log); + const execFile = deps.execFile || promisify(execFileCb); + // Node 18+ exposes a global `fetch` (the package `engines` requires >=18). Read it off `global` + // (defined in the node ESLint env) so no polyfill/dependency is pulled in. + const fetchFn = deps.fetch || global.fetch; + const sleep = deps.delay || delay; + + const docker = (...args) => execFile('docker', args); + // Never let a probe/teardown throw — these are best-effort checks, not the happy path. + const dockerQuiet = async (...args) => { + try { + await docker(...args); + return true; + } catch (err) { + return false; + } + }; + + // AC6: preflight — fail fast BEFORE any `docker run`, so nothing is left dangling. + try { + await docker('version'); + } catch (err) { + throw dockerUnavailableError(err); + } + + // AC9: honor the pull policy. `missing` => inspect, pull only on a miss; `always` => always pull. + if (opts.pullPolicy === 'always') { + await docker('pull', opts.image); + } else if (opts.pullPolicy === 'missing') { + const present = await dockerQuiet('image', 'inspect', opts.image); + if (!present) await docker('pull', opts.image); + } + + // Idempotent reclaim: force-remove a leaked container of our fixed name before (re)creating it. + await dockerQuiet('rm', '-f', opts.name); + + await docker(...buildContainerArgs(opts)); + + const endpoint = buildReadinessUrl(opts); + + // Guarded-idempotent teardown (AC4): `stop` removes the container at most once. + const stopState = {stopped: false}; + const stop = async () => { + if (stopState.stopped) return; + stopState.stopped = true; + logger.notice('Stopping autostarted ElasticMQ container'); + await dockerQuiet('stop', opts.name); + await dockerQuiet('rm', '-f', opts.name); + }; + + // AC7: poll the SQS port until ANY HTTP answer (even a 4xx means the port is up). A + // connection-refused while booting is the expected not-ready signal — swallow + retry until the + // readinessTimeout, then tear the container down and throw a descriptive error. + const deadline = Date.now() + opts.readinessTimeout; + const waitUntilReady = async () => { + try { + await fetchFn(endpoint); + return; + } catch (err) { + if (Date.now() >= deadline) { + await stop(); + throw new Error( + `serverless-offline-sqs autoStart: ElasticMQ did not become ready at ${endpoint} ` + + `within ${opts.readinessTimeout}ms. Is the port free? Override with ` + + 'custom.serverless-offline-sqs.autoStart.port.' + ); + } + await sleep(READINESS_POLL_INTERVAL); + return waitUntilReady(); + } + }; + await waitUntilReady(); + + logger.notice(`Started autostarted ElasticMQ container at ${endpoint}`); + + return {endpoint, stop}; +}; + +module.exports = { + startElasticMq, + isAutoStartEnabled, + resolveAutoStartOptions, + buildContainerArgs, + buildReadinessUrl, + ensureLocalCredentials, + DEFAULT_AUTOSTART_IMAGE, + DEFAULT_AUTOSTART_PORT, + DEFAULT_PULL_POLICY, + DEFAULT_READINESS_TIMEOUT, + DEFAULT_CONTAINER_NAME, + LOCAL_CREDENTIAL +}; diff --git a/packages/serverless-offline-sqs/src/index.js b/packages/serverless-offline-sqs/src/index.js index abc4e6a..1a8b4d4 100644 --- a/packages/serverless-offline-sqs/src/index.js +++ b/packages/serverless-offline-sqs/src/index.js @@ -15,6 +15,12 @@ const { const {normalizeLog} = require('./log'); const SQS = require('./sqs'); +const { + startElasticMq, + isAutoStartEnabled, + resolveAutoStartOptions, + ensureLocalCredentials +} = require('./elasticmq'); const OFFLINE_OPTION = 'serverless-offline'; const CUSTOM_OPTION = 'serverless-offline-sqs'; @@ -90,21 +96,57 @@ class ServerlessOfflineSQS { this._mergeOptions(); - const {sqsEvents, lambdas} = this._getEvents(); + // #146 (tstackhouse, tristan-mastrodicasa): opt-in autoStart — spawn an ElasticMQ container and + // point the SQS client at it BEFORE any queue is created or polled. Setting this.options.endpoint + // is enough: buildClientConfig passes it through and sqs._rewriteQueueUrl rewrites every queue URL + // to it (AC2/AC3/AC10). If the user ALSO set an explicit endpoint, autoStart is skipped in its + // favour with a warning (AC5). Default-off path is untouched (AC1). + await this._startAutoStartElasticMq(); - await this._createLambda(lambdas); + try { + const {sqsEvents, lambdas} = this._getEvents(); - const eventModules = []; + await this._createLambda(lambdas); + + const eventModules = []; - // #222 (gndelia): skip SQS setup entirely when disabled, but still create the lambdas so plain - // HTTP functions keep working under serverless-offline. - if (isPluginEnabled(this.options) && sqsEvents.length > 0) { - eventModules.push(this._createSqs(sqsEvents)); + // #222 (gndelia): skip SQS setup entirely when disabled, but still create the lambdas so plain + // HTTP functions keep working under serverless-offline. + if (isPluginEnabled(this.options) && sqsEvents.length > 0) { + eventModules.push(this._createSqs(sqsEvents)); + } + + await Promise.all(eventModules); + + this.log.notice( + `Starting Offline SQS at stage ${this.options.stage} (${this.options.region})` + ); + } catch (err) { + // #146: if a later start step fails after autoStart spun up a container, tear it down so a + // failed boot never leaks the container (belt-and-suspenders to the --rm + reclaim-on-next-start net). + if (this.elasticmq) await this.elasticmq.stop(); + throw err; } + } - await Promise.all(eventModules); + // #146: thin orchestration only — all Docker logic lives in the pure/side-effect elasticmq module. + async _startAutoStartElasticMq() { + if (!isAutoStartEnabled(this.options)) return; + + // AC5: an explicit user endpoint wins; do NOT start a container, just warn. + if (this.options.endpoint) { + this.log.warning( + `serverless-offline-sqs: autoStart is enabled but an explicit endpoint (${this.options.endpoint}) ` + + 'is set — skipping autoStart and using the configured endpoint.' + ); + return; + } - this.log.notice(`Starting Offline SQS at stage ${this.options.stage} (${this.options.region})`); + this.elasticmq = await startElasticMq(resolveAutoStartOptions(this.options), this.log); + this.options.endpoint = this.elasticmq.endpoint; + // Zero-setup: ElasticMQ ignores credential values, but the v3 SQS client needs SOME credentials + // to sign. Inject local placeholders unless the user already configured a pair (#146). + this.options = ensureLocalCredentials(this.options); } ready() { @@ -147,6 +189,13 @@ class ServerlessOfflineSQS { eventModules.push(this.sqs.stop(SERVER_SHUTDOWN_TIMEOUT)); } + // #146 (AC4): tear down the autostarted ElasticMQ container before the process exits. SIGINT/ + // SIGTERM already route through end() via _listenForTermination, and elasticmq.stop() is + // guarded-idempotent, so a SIGINT followed by offline:start:end is a harmless no-op the 2nd time. + if (this.elasticmq) { + eventModules.push(this.elasticmq.stop()); + } + await Promise.all(eventModules); if (!skipExit) { diff --git a/packages/serverless-offline-sqs/test/elasticmq.integration.js b/packages/serverless-offline-sqs/test/elasticmq.integration.js new file mode 100644 index 0000000..98ed36a --- /dev/null +++ b/packages/serverless-offline-sqs/test/elasticmq.integration.js @@ -0,0 +1,151 @@ +const {execFile: execFileCb} = require('child_process'); +const {promisify} = require('util'); +const test = require('ava'); +const { + SQSClient, + SendMessageCommand, + ReceiveMessageCommand, + GetQueueUrlCommand +} = require('@aws-sdk/client-sqs'); + +const ServerlessOfflineSQS = require('../src'); + +// #146 (tstackhouse, tristan-mastrodicasa): a REAL, docker-gated integration test for autoStart. +// It boots an actual ElasticMQ container from the plugin lifecycle (autoStart:true + autoCreate:true, +// no endpoint), proves a queue is created INSIDE the container and a message round-trips, then ends() +// and proves the container is gone. Gated behind a `docker version` probe so a Docker-less CI skips +// it; a UNIQUE container name + afterEach `docker rm -f` guarantee nothing leaks. + +const execFile = promisify(execFileCb); +const docker = (...args) => execFile('docker', args); +const dockerQuiet = async (...args) => { + try { + return await docker(...args); + } catch (err) { + return undefined; + } +}; + +// A unique-per-run host port + container name so parallel/leftover runs never collide. +const TEST_PORT = 9399; +const TEST_NAME = `so-sqs-autostart-it-${process.pid}`; +// EVENT_QUEUE is wired to the lambda event (triggers _createSqs + a poll loop). RT_QUEUE is a +// resource-only queue with NO consumer, so the round-trip assertion below is deterministic (no +// listener races us for the message). autoCreate creates BOTH inside the autostarted container. +const EVENT_QUEUE = 'autostart-it-event'; +const RT_QUEUE = 'autostart-it-roundtrip'; +const silentLog = {notice: () => {}, warning: () => {}, debug: () => {}, info: () => {}}; + +const dockerAvailable = async () => { + try { + await docker('version'); + return true; + } catch (err) { + return false; + } +}; + +const containerRunning = async name => { + const {stdout} = await docker('ps', '--filter', `name=${name}`, '--format', '{{.Names}}'); + return stdout + .split('\n') + .map(s => s.trim()) + .includes(name); +}; + +// A minimal serverless mock. One function carries a real `sqs` event (so the plugin runs the genuine +// _createSqs path that autoCreates queues and attaches a listener — AC10), and a second round-trip +// queue is declared in resources.Resources so autoCreate creates it too, without a consumer. +const eventArn = `arn:aws:sqs:eu-west-1:000000000000:${EVENT_QUEUE}`; +const buildServerless = () => ({ + service: { + custom: { + 'serverless-offline-sqs': { + autoStart: {port: TEST_PORT, name: TEST_NAME, readinessTimeout: 60000}, + autoCreate: true, + region: 'eu-west-1', + accountId: '000000000000' + } + }, + provider: {region: 'eu-west-1'}, + resources: { + Resources: { + RtQueue: {Type: 'AWS::SQS::Queue', Properties: {QueueName: RT_QUEUE}} + } + }, + getAllFunctions: () => ['worker'], + getFunction: () => ({handler: 'handler.worker', events: [{sqs: {arn: eventArn}}]}), + getAllEventsInFunction: () => [{sqs: {arn: eventArn}}] + } +}); + +let skip = false; + +test.before(async () => { + skip = !(await dockerAvailable()); + if (!skip) await dockerQuiet('rm', '-f', TEST_NAME); // reclaim a leak from a previous crashed run +}); + +test.after.always(async () => { + await dockerQuiet('rm', '-f', TEST_NAME); +}); + +test.serial( + '#146 INTEGRATION autoStart boots ElasticMQ, autoCreates a queue, round-trips a message, tears down (AC2/AC3/AC4/AC10)', + async t => { + if (skip) { + t.pass('docker unavailable — integration test skipped'); + return; + } + + const plugin = new ServerlessOfflineSQS(buildServerless(), {}, {log: silentLog}); + // Stub only the heavyweight serverless-offline lambda pool; the autoStart + autoCreate paths are + // the real thing (real container, real @aws-sdk SQS client against the container). The poll-loop + // handler is a no-op; a failing get() would only be logged via enqueueLoop, never thrown. + const lambdaStub = {setEvent: () => {}, runHandler: () => Promise.resolve()}; + plugin.lambda = {get: () => lambdaStub, cleanup: () => Promise.resolve()}; + plugin._createLambda = () => Promise.resolve(); + + await plugin.start(); + + // Halt the event-queue poll loop so it cannot busy-loop against the container once it's torn + // down, and so it never races the round-trip below. The autoCreate of both queues has already + // happened during start(); pausing only stops further ReceiveMessage polling. + plugin.sqs.queue.pause(); + + // AC2: the endpoint was set to the autostarted container BEFORE any queue op. + t.is(plugin.options.endpoint, `http://localhost:${TEST_PORT}`); + // AC2: the container is actually running. + t.true(await containerRunning(TEST_NAME)); + + // AC3/AC10: the queues were created INSIDE the container, and a message round-trips through one. + const client = new SQSClient({ + endpoint: `http://localhost:${TEST_PORT}`, + region: 'eu-west-1', + credentials: {accessKeyId: 'local', secretAccessKey: 'local'} + }); + + // AC10: the event-wired queue was autoCreated inside the container. + const {QueueUrl: eventUrl} = await client.send( + new GetQueueUrlCommand({QueueName: EVENT_QUEUE}) + ); + t.truthy(eventUrl); + + // AC3: a message round-trips through the (consumer-less) resource queue created in the container. + const {QueueUrl} = await client.send(new GetQueueUrlCommand({QueueName: RT_QUEUE})); + t.truthy(QueueUrl); + + await client.send(new SendMessageCommand({QueueUrl, MessageBody: 'hello-autostart'})); + const received = await client.send( + new ReceiveMessageCommand({QueueUrl, MaxNumberOfMessages: 1, WaitTimeSeconds: 5}) + ); + t.is(received.Messages.length, 1); + t.is(received.Messages[0].Body, 'hello-autostart'); + + client.destroy(); + + // AC4: end() stops + removes the container. + await plugin.end(true); // skipExit so the test process survives + t.false(await containerRunning(TEST_NAME)); + } +); diff --git a/packages/serverless-offline-sqs/test/index.js b/packages/serverless-offline-sqs/test/index.js index 61b3bf2..1b6a325 100644 --- a/packages/serverless-offline-sqs/test/index.js +++ b/packages/serverless-offline-sqs/test/index.js @@ -30,7 +30,19 @@ const { dispatchDestination, runDestinations } = require('../src/destinations'); +const ServerlessOfflineSQS = require('../src'); const {defaultOptions, isPluginEnabled} = require('../src'); +const { + isAutoStartEnabled, + resolveAutoStartOptions, + buildContainerArgs, + buildReadinessUrl, + ensureLocalCredentials, + startElasticMq, + DEFAULT_AUTOSTART_IMAGE, + DEFAULT_AUTOSTART_PORT, + LOCAL_CREDENTIAL +} = require('../src/elasticmq'); const {extractQueueNameFromARN, resolveCfnValue} = require('../src/sqs-event-definition'); const { buildClientConfig, @@ -1574,3 +1586,345 @@ test('SQS._dispatchDestination resolves a {Ref: } destination via this.re // No explicit QueueName -> falls back to the logical id. t.is(client.calls[0].input.QueueName, 'OkQueue'); }); + +// --------------------------------------------------------------------------- +// #146 (tstackhouse, tristan-mastrodicasa): opt-in autoStart ElasticMQ. +// Pure helpers + Docker lifecycle (mocked child_process + fetch — no real Docker). +// --------------------------------------------------------------------------- + +// AC8 (string coercion) + AC1 (default off): isAutoStartEnabled mirrors isPluginEnabled. +test('#146 isAutoStartEnabled defaults to false when autoStart is absent (opt-in, AC1)', t => { + t.false(isAutoStartEnabled({})); + t.false(isAutoStartEnabled(undefined)); + t.false(isAutoStartEnabled({region: 'eu-west-1'})); +}); + +test('#146 isAutoStartEnabled is true for boolean true and an object config form', t => { + t.true(isAutoStartEnabled({autoStart: true})); + t.true(isAutoStartEnabled({autoStart: {port: 9325}})); +}); + +test('#146 isAutoStartEnabled coerces the YAML/CLI strings "true"/"false" (AC8)', t => { + t.true(isAutoStartEnabled({autoStart: 'true'})); + t.false(isAutoStartEnabled({autoStart: 'false'})); +}); + +test('#146 isAutoStartEnabled honors an explicit boolean false', t => { + t.false(isAutoStartEnabled({autoStart: false})); +}); + +test('#146 isAutoStartEnabled is a pure read with no side effects', t => { + const opts = {autoStart: 'true', region: 'eu-west-1'}; + const before = {...opts}; + isAutoStartEnabled(opts); + t.deepEqual(opts, before); +}); + +// AC9: resolveAutoStartOptions — pinned defaults, user overrides honored. +test('#146 resolveAutoStartOptions falls back to pinned defaults when nothing is set (AC9)', t => { + const resolved = resolveAutoStartOptions({autoStart: true}); + t.is(resolved.image, DEFAULT_AUTOSTART_IMAGE); + t.is(resolved.image, 'softwaremill/elasticmq-native:1.6.11'); + t.is(resolved.port, DEFAULT_AUTOSTART_PORT); + t.is(resolved.port, 9324); + t.is(resolved.pullPolicy, 'missing'); + t.is(resolved.readinessTimeout, 30000); + t.is(typeof resolved.name, 'string'); + t.true(resolved.name.length > 0); +}); + +test('#146 resolveAutoStartOptions defaults work when autoStart is the bare boolean true', t => { + t.is(resolveAutoStartOptions({autoStart: true}).port, 9324); + t.is(resolveAutoStartOptions({autoStart: 'true'}).port, 9324); +}); + +test('#146 resolveAutoStartOptions honors image/port/pullPolicy overrides (AC9)', t => { + const resolved = resolveAutoStartOptions({ + autoStart: { + image: 'softwaremill/elasticmq-native:1.5.0', + port: 9555, + pullPolicy: 'always', + readinessTimeout: 1000, + name: 'my-mq' + } + }); + t.is(resolved.image, 'softwaremill/elasticmq-native:1.5.0'); + t.is(resolved.port, 9555); + t.is(resolved.pullPolicy, 'always'); + t.is(resolved.readinessTimeout, 1000); + t.is(resolved.name, 'my-mq'); +}); + +test('#146 resolveAutoStartOptions does not mutate its input', t => { + const opts = {autoStart: {port: 9555}}; + const before = JSON.parse(JSON.stringify(opts)); + resolveAutoStartOptions(opts); + t.deepEqual(opts, before); +}); + +// AC2/AC9: buildContainerArgs — the exact `docker run` argv. +test('#146 buildContainerArgs builds the detached --rm --name -p run argv (AC2)', t => { + const argv = buildContainerArgs({ + name: 'so-sqs', + port: 9324, + image: 'softwaremill/elasticmq-native:1.6.11' + }); + t.deepEqual(argv, [ + 'run', + '-d', + '--rm', + '--name', + 'so-sqs', + '-p', + '9324:9324', + 'softwaremill/elasticmq-native:1.6.11', + '-Dnode-address.host=*' + ]); +}); + +test('#146 buildContainerArgs threads a custom port and image into the argv (AC9)', t => { + const argv = buildContainerArgs({ + name: 'so-sqs', + port: 9555, + image: 'softwaremill/elasticmq-native:1.5.0' + }); + t.true(argv.includes('9555:9324')); + t.true(argv.includes('softwaremill/elasticmq-native:1.5.0')); +}); + +// AC2: buildReadinessUrl — localhost on the configured port. +test('#146 buildReadinessUrl points at http://localhost: (AC2)', t => { + t.is(buildReadinessUrl({port: 9324}), 'http://localhost:9324'); + t.is(buildReadinessUrl({port: 9555}), 'http://localhost:9555'); +}); + +// Zero-setup: ensureLocalCredentials injects placeholders only when the user set neither key. +test('#146 ensureLocalCredentials injects local placeholders when no credentials are set', t => { + const resolved = ensureLocalCredentials({region: 'eu-west-1'}); + t.is(resolved.accessKeyId, LOCAL_CREDENTIAL); + t.is(resolved.secretAccessKey, LOCAL_CREDENTIAL); + t.is(resolved.region, 'eu-west-1'); +}); + +test('#146 ensureLocalCredentials leaves a user-supplied credential pair untouched', t => { + const options = {accessKeyId: 'mine', secretAccessKey: 'secret'}; + t.is(ensureLocalCredentials(options), options); +}); + +test('#146 ensureLocalCredentials does not override a partial user credential (only one key set)', t => { + // A half-set pair is the user's responsibility; we never silently fill the gap (buildCredentials + // already drops a half-empty pair, mirroring #252). + const options = {accessKeyId: 'mine'}; + t.is(ensureLocalCredentials(options), options); + t.is(ensureLocalCredentials(options).secretAccessKey, undefined); +}); + +test('#146 ensureLocalCredentials does not mutate its input', t => { + const options = {region: 'eu-west-1'}; + const before = {...options}; + ensureLocalCredentials(options); + t.deepEqual(options, before); +}); + +// ---- startElasticMq lifecycle, with an injected (mocked) execFile + fetch ---- + +// A recording fake of the child_process execFile contract used by elasticmq.js. `outcomes` maps a +// matched subcommand (the first arg, e.g. 'version'/'image'/'pull'/'run'/'stop') to either a thrown +// error or a stdout string. +const fakeExecFile = + (calls, outcomes = {}) => + (file, args) => { + calls.push({file, args}); + const sub = args[0]; + const outcome = outcomes[sub]; + if (outcome instanceof Error) return Promise.reject(outcome); + return Promise.resolve({stdout: outcome === undefined ? '' : outcome, stderr: ''}); + }; + +const silentLog = {notice: () => {}, warning: () => {}, debug: () => {}}; +const isTeardownSub = sub => sub === 'stop' || sub === 'rm'; + +test('#146 startElasticMq fails fast with a Docker-naming error when docker is absent (AC6)', async t => { + const calls = []; + const dockerAbsent = Object.assign(new Error('spawn docker ENOENT'), {code: 'ENOENT'}); + const execFile = fakeExecFile(calls, {version: dockerAbsent}); + + const error = await t.throwsAsync(() => + startElasticMq( + {name: 'so-sqs-test', port: 9324, image: 'img', pullPolicy: 'missing', readinessTimeout: 50}, + silentLog, + {execFile, fetch: () => Promise.resolve({})} + ) + ); + t.regex(error.message, /[Dd]ocker/); + // and it must NOT have attempted to run a container (nothing to leave dangling) + t.false(calls.some(({args}) => args[0] === 'run')); +}); + +test('#146 startElasticMq tears the container down and throws on a readiness timeout (AC7)', async t => { + const calls = []; + const execFile = fakeExecFile(calls, {}); // every docker subcommand "succeeds" + // fetch never answers (connection refused) -> readiness never reached + const fetch = () => + Promise.reject(Object.assign(new Error('ECONNREFUSED'), {code: 'ECONNREFUSED'})); + + const error = await t.throwsAsync(() => + startElasticMq( + {name: 'so-sqs-test', port: 9324, image: 'img', pullPolicy: 'missing', readinessTimeout: 60}, + silentLog, + {execFile, fetch} + ) + ); + t.regex(error.message, /ready|timeout/i); + // the container that was started must have been torn down (stop/rm issued AFTER run) + const runIdx = calls.findIndex(({args}) => args[0] === 'run'); + t.true(runIdx >= 0); + const teardown = calls.slice(runIdx + 1).some(({args}) => isTeardownSub(args[0])); + t.true(teardown); +}); + +test('#146 startElasticMq happy path resolves {endpoint, stop} and pulls only on inspect miss', async t => { + const calls = []; + // pullPolicy 'missing': `image inspect` fails -> a `pull` must follow. + const execFile = fakeExecFile(calls, {image: new Error('No such image')}); + const fetch = () => Promise.resolve({ok: true, status: 200}); + + const handle = await startElasticMq( + {name: 'so-sqs-test', port: 9324, image: 'img', pullPolicy: 'missing', readinessTimeout: 1000}, + silentLog, + {execFile, fetch} + ); + + t.is(handle.endpoint, 'http://localhost:9324'); + t.is(typeof handle.stop, 'function'); + const subs = calls.map(({args}) => args[0]); + t.true(subs.includes('version')); // preflight + t.true(subs.includes('image')); // inspect probe + t.true(subs.includes('pull')); // inspect failed -> pull + t.true(subs.includes('run')); // container started + // idempotent: a leaked is force-removed before run + const rmBeforeRun = calls.findIndex(({args}) => args[0] === 'rm'); + const runIdx = calls.findIndex(({args}) => args[0] === 'run'); + t.true(rmBeforeRun >= 0 && rmBeforeRun < runIdx); +}); + +test('#146 startElasticMq with pullPolicy missing skips pull when the image is already present', async t => { + const calls = []; + const execFile = fakeExecFile(calls, {}); // `image inspect` succeeds + const fetch = () => Promise.resolve({ok: true, status: 200}); + + await startElasticMq( + {name: 'so-sqs-test', port: 9324, image: 'img', pullPolicy: 'missing', readinessTimeout: 1000}, + silentLog, + {execFile, fetch} + ); + t.false(calls.some(({args}) => args[0] === 'pull')); +}); + +test('#146 startElasticMq stop() is idempotent — the 2nd call is a no-op (AC4)', async t => { + const calls = []; + const execFile = fakeExecFile(calls, {}); + const fetch = () => Promise.resolve({ok: true, status: 200}); + + const handle = await startElasticMq( + {name: 'so-sqs-test', port: 9324, image: 'img', pullPolicy: 'missing', readinessTimeout: 1000}, + silentLog, + {execFile, fetch} + ); + + await handle.stop(); + const afterFirst = calls.filter(({args}) => isTeardownSub(args[0])).length; + await handle.stop(); // must be a no-op + const afterSecond = calls.filter(({args}) => isTeardownSub(args[0])).length; + t.is(afterFirst, afterSecond); +}); + +// --------------------------------------------------------------------------- +// #146 index.js wiring — autoStart precedence + teardown (no real Docker). +// --------------------------------------------------------------------------- + +// AC1: with autoStart UNSET, start() never touches this.options.endpoint. +test('#146 start() leaves the endpoint untouched when autoStart is unset (AC1)', async t => { + const serverless = {service: {custom: {}, provider: {}, getAllFunctions: () => []}}; + const plugin = new ServerlessOfflineSQS(serverless, {}, {log: silentLog}); + plugin._createLambda = async () => {}; + plugin._createSqs = async () => {}; + + await plugin.start(); + + t.is(plugin.options.endpoint, undefined); + t.is(plugin.elasticmq, undefined); +}); + +// AC5: autoStart enabled AND an explicit endpoint -> skip the container, keep the user endpoint. +test('#146 start() skips autoStart and warns when an explicit endpoint is set (AC5)', async t => { + const warnings = []; + const serverless = { + service: { + custom: {'serverless-offline-sqs': {autoStart: true, endpoint: 'http://localhost:4576'}}, + provider: {}, + getAllFunctions: () => [] + } + }; + const plugin = new ServerlessOfflineSQS( + serverless, + {}, + { + log: {...silentLog, warning: msg => warnings.push(msg)} + } + ); + plugin._createLambda = async () => {}; + plugin._createSqs = async () => {}; + + await plugin.start(); + + t.is(plugin.options.endpoint, 'http://localhost:4576'); // user endpoint preserved + t.is(plugin.elasticmq, undefined); // no container started + t.true(warnings.some(msg => /autoStart/i.test(String(msg)))); +}); + +// #146 leak hardening: if a start() step AFTER autoStart fails, the container is torn down (no leak). +test('#146 start() tears the container down if a later start step throws', async t => { + let stopped = 0; + const serverless = {service: {custom: {}, provider: {}, getAllFunctions: () => []}}; + const plugin = new ServerlessOfflineSQS(serverless, {}, {log: silentLog}); + // simulate autoStart having spun up a container, then fail a later boot step + plugin._startAutoStartElasticMq = () => { + plugin.elasticmq = { + stop: () => { + stopped += 1; + return Promise.resolve(); + } + }; + return Promise.resolve(); + }; + plugin._createLambda = () => Promise.reject(new Error('boom during lambda init')); + plugin._createSqs = () => Promise.resolve(); + + await t.throwsAsync(() => plugin.start(), {message: /boom/}); + t.is(stopped, 1, 'the autostarted container was stopped on the failed boot'); +}); + +// AC4: end() awaits elasticmq.stop() when present, and is harmless when absent. +test('#146 end() awaits elasticmq.stop() when a container was started (AC4)', async t => { + let stopped = 0; + const serverless = {service: {custom: {}, provider: {}}}; + const plugin = new ServerlessOfflineSQS(serverless, {}, {log: silentLog}); + plugin.elasticmq = { + endpoint: 'http://localhost:9324', + stop: () => { + stopped += 1; + return Promise.resolve(); + } + }; + + await plugin.end(true); // skipExit=true so the test process is not killed + + t.is(stopped, 1); +}); + +test('#146 end() is a no-op for the container when none was started (AC1)', async t => { + const serverless = {service: {custom: {}, provider: {}}}; + const plugin = new ServerlessOfflineSQS(serverless, {}, {log: silentLog}); + await t.notThrowsAsync(() => plugin.end(true)); +});