diff --git a/docs/sf/guides/compose.md b/docs/sf/guides/compose.md index ebe42d31d04..0d5f7efcdc0 100644 --- a/docs/sf/guides/compose.md +++ b/docs/sf/guides/compose.md @@ -196,6 +196,24 @@ All Serverless Framework commands are supported **only via service-specific comm serverless service-a offline ``` +### Running a command on a subset of services + +`--service` also accepts a comma-separated list, to run a command on several named services at once: + +```bash +serverless deploy --service=service-a,service-d +``` + +The command runs on **exactly** the services you name, ordered among themselves by their `dependsOn` relationships (so a dependency is deployed before the service that depends on it). Services you don't name are left untouched — they are neither deployed nor removed. Their outputs are still available for `${param:xxx}` resolution when a named service references them, so a subset can depend on services that are already deployed elsewhere. + +This is useful when different services in the project have different lifecycles — for example, deploying the application services to a personal or preview stage while leaving a shared, long-lived service in place: + +```bash +serverless deploy --service=service-a,service-d --stage my-feature +``` + +The same list works with `remove`, `info`, `print`, and `package`. + ### Service-specific commands when using parameters The `serverless service-a deploy` command is the equivalent of running `serverless deploy` in service-a's directory. Both can be used. diff --git a/packages/sf-core/package.json b/packages/sf-core/package.json index 6c3e35ec2df..75710918594 100644 --- a/packages/sf-core/package.json +++ b/packages/sf-core/package.json @@ -16,6 +16,8 @@ "test:simple:python": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_NO_WARNINGS=1 jest tests/integration/simple-python/**", "test:simple:dashboard": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_NO_WARNINGS=1 jest tests/integration/simple-dashboard/**", "test:simple:compose": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_NO_WARNINGS=1 jest tests/integration/simple-compose/**", + "test:compose:subset": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_NO_WARNINGS=1 jest tests/integration/compose-service-subset/**", + "test:compose:dev": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_NO_WARNINGS=1 jest tests/integration/compose-dev/**", "test:simple:resolvers": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_NO_WARNINGS=1 jest tests/integration/resolvers/**", "test:resolvers": "NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_NO_WARNINGS=1 jest tests/resolvers/**", "test:esbuild": "cross-env NODE_OPTIONS=\"$NODE_OPTIONS --experimental-vm-modules\" NODE_NO_WARNINGS=1 jest tests/integration/esbuild/**", diff --git a/packages/sf-core/src/lib/runners/compose/compose.js b/packages/sf-core/src/lib/runners/compose/compose.js index 799d2c79258..e4649e95481 100644 --- a/packages/sf-core/src/lib/runners/compose/compose.js +++ b/packages/sf-core/src/lib/runners/compose/compose.js @@ -111,6 +111,22 @@ export class ComposeRunner extends Runner { } } +/** + * Parse the `--service` option: a service name or a comma-separated list. + * + * @param {unknown} value + * @returns {string[]} + */ +const parseServiceOption = (value) => { + if (!value) { + return [] + } + return String(value) + .split(',') + .map((name) => name.trim()) + .filter(Boolean) +} + /** * @typedef {Object} RunComposeOptions * @property {string} configFile @@ -174,8 +190,40 @@ const runCompose = async ({ err.stack = undefined throw err } + const serviceNames = Object.keys(composeConfigFile?.services || {}) + if (command.join(' ') === 'dev') { + // `dev` is a single-service, interactive session — teach the exact commands + const err = new ServerlessError( + `In a Compose project, "dev" runs on one service at a time. Start it for a specific service:\n${serviceNames + .map((name) => ` serverless ${name} dev`) + .join('\n')}`, + ServerlessErrorCodes.compose.COMPOSE_COMMAND_NOT_SUPPORTED, + ) + err.stack = undefined + throw err + } + const err = new ServerlessError( + `The command "${command.join(' ')}" is not currently supported at the project level by Compose. Project-wide commands: ${supportedComposeCommands.join(', ')}. To run it for a single service: serverless ${command.join(' ')}.`, + ServerlessErrorCodes.compose.COMPOSE_COMMAND_NOT_SUPPORTED, + ) + err.stack = undefined + throw err + } + + const serviceOptionProvided = Boolean(options?.service) + const serviceNames = parseServiceOption(options?.service) + // The parsed list drives routing from here on. Drop the raw option so a + // malformed value can't leak to the runner and fail Framework schema + // validation downstream (executeSingleComponent/executeSubsetComponents + // delete it too; the whole-graph fallback below would not). + delete options.service + + // A --service value that was provided but resolves to no usable names + // (e.g. `--service=,` or `--service=" "`) is a mistake, not a request to + // run the whole graph — fail clearly instead of silently fanning out. + if (serviceOptionProvided && serviceNames.length === 0) { const err = new ServerlessError( - `The command "${command.join(' ')}" is not currently supported by Compose.`, + `No services were resolved from --service. Provide a comma-separated list of service names, e.g.: serverless ${command.join(' ')} --service=,.`, ServerlessErrorCodes.compose.COMPOSE_COMMAND_NOT_SUPPORTED, ) err.stack = undefined @@ -201,9 +249,9 @@ const runCompose = async ({ getServiceState, } - if (options?.service) { + if (serviceNames.length === 1) { await composeService.executeSingleComponent({ - serviceName: options.service, + serviceName: serviceNames[0], reverse: false, composeOrgName: orgName, command, @@ -213,6 +261,20 @@ const runCompose = async ({ state, }) } else { + if ( + serviceNames.length > 1 && + !supportedComposeCommands.includes(command.join(' ')) + ) { + // Subset runs support the project-wide commands only; interactive, + // single-service commands like `dev` cannot fan out over a list + const err = new ServerlessError( + `The command "${command.join(' ')}" runs on one service at a time and cannot be used with a service list. Run it for a single service, e.g.: serverless ${serviceNames[0]} ${command.join(' ')}.`, + ServerlessErrorCodes.compose.COMPOSE_COMMAND_NOT_SUPPORTED, + ) + err.stack = undefined + throw err + } + if ( getGlobalRendererSettings().logLevel === 'notice' || getGlobalRendererSettings().logLevel === 'info' @@ -237,15 +299,28 @@ Docs: https://www.serverless.com/framework/docs/guides/compose ) } - await composeService.executeComponentsGraph({ - command, - reverse: false, - composeOrgName: orgName, - options, - resolverProviders: resolverManager.getResolverProviders(), - params: resolverManager.params, - state, - }) + if (serviceNames.length > 1) { + await composeService.executeSubsetComponents({ + serviceNames, + command, + reverse: false, + composeOrgName: orgName, + options, + resolverProviders: resolverManager.getResolverProviders(), + params: resolverManager.params, + state, + }) + } else { + await composeService.executeComponentsGraph({ + command, + reverse: false, + composeOrgName: orgName, + options, + resolverProviders: resolverManager.getResolverProviders(), + params: resolverManager.params, + state, + }) + } composeService.printRunReport({ command }) } } catch (err) { @@ -259,4 +334,4 @@ Docs: https://www.serverless.com/framework/docs/guides/compose } } -export { runCompose } +export { runCompose, parseServiceOption } diff --git a/packages/sf-core/src/lib/runners/compose/help.js b/packages/sf-core/src/lib/runners/compose/help.js index b6694ea3c98..558f4314407 100644 --- a/packages/sf-core/src/lib/runners/compose/help.js +++ b/packages/sf-core/src/lib/runners/compose/help.js @@ -27,6 +27,10 @@ const commands = [ command: 'print', description: 'Print the configuration of all services', }, + { + command: 'package', + description: 'Package all services', + }, ] /** @@ -71,6 +75,12 @@ export default async () => { log.aside('Global options') log.notice(formatLine('--stage', 'Stage of the service')) log.notice(formatLine('--region', 'Region of the service')) + log.notice( + formatLine( + '--service', + 'Run on specific service(s), comma-separated (e.g. --service=api,worker)', + ), + ) log.notice(formatLine('--verbose', 'Enable verbose logs')) log.notice(formatLine('--debug', 'Enable debug mode')) log.blankLine() diff --git a/packages/sf-core/src/lib/runners/compose/index.js b/packages/sf-core/src/lib/runners/compose/index.js index 62bdba6ade8..67389616783 100644 --- a/packages/sf-core/src/lib/runners/compose/index.js +++ b/packages/sf-core/src/lib/runners/compose/index.js @@ -319,16 +319,41 @@ class Compose { if (isParamReference) { const splitKey = value.split('.') + const depService = splitKey[0] + const outputKey = splitKey[1] const stateValue = - state?.localState?.[splitKey[0]]?.outputs?.[splitKey[1]] - if (!stateValue) { + state?.localState?.[depService]?.outputs?.[outputKey] + // Use an explicit undefined check: a resolved output can be an + // empty string, which must pass through rather than be treated + // as missing. + if (stateValue === undefined) { if (command[0] === 'print') { serviceParams[key] = 'NOT_AVAILABLE_IN_PRINT_COMMAND' } else if (command[0] === 'remove') { serviceParams[key] = '' + } else if (state?.localState?.[depService]) { + // The dependency's state IS present, but the referenced output + // key is missing — almost always a typo in the output name. + // Do NOT advise (re)deploying an already-deployed service (I2). + const availableOutputs = Object.keys( + state.localState[depService].outputs || {}, + ) + const availableList = + availableOutputs.length > 0 + ? availableOutputs.join(', ') + : '(none)' + throw new ServerlessError( + `Could not resolve the parameter '${key}': service '${depService}' has no output '${outputKey}'. Available outputs: ${availableList}. Check the output name in your reference.`, + ServerlessErrorCodes.compose + .COMPOSE_COULD_NOT_RESOLVE_PARAM, + { stack: false }, + ) } else { + const stageFlag = options?.stage + ? ` --stage ${options.stage}` + : '' throw new ServerlessError( - `Could not resolve the parameter '${key}'. Please ensure that it is correctly defined in the Compose configuration and that all dependent services are deployed. If the services are deployed, verify that their state is up to date by running 'deploy' or 'info' command on the Compose file.`, + `Could not resolve the parameter '${key}': no deployed state found for service '${depService}'. Deploy it first with 'serverless deploy --service=${depService}${stageFlag}', then retry. If it is already deployed, refresh its state with 'serverless ${depService} info${stageFlag}'.`, ServerlessErrorCodes.compose .COMPOSE_COULD_NOT_RESOLVE_PARAM, { stack: false }, @@ -466,6 +491,7 @@ class Compose { params, runnerFunction, // Pass the runner function along to the next recursive call state, + isMultipleComponents, // Propagate so a single-mode error still rethrows on later graph levels }) } @@ -598,6 +624,121 @@ class Compose { }) } + /** + * Execute a command on an exact subset of services (`--service=a,b`). + * + * Semantics — "exactly the named set": the command runs on precisely the + * named services, dependency-ordered among themselves. Dependencies that + * are NOT named are read (get-state) so `${param:}` references resolve, + * but they are never deployed or removed. + * + * @param {string[]} serviceNames + * @param {string[]} command + * @param {boolean} reverse + * @param {string} composeOrgName + * @param {Record} options + * @param {Record} resolverProviders + * @param {Record} params + * @param {State} state + * @returns {Promise} + */ + async executeSubsetComponents({ + serviceNames, + command, + reverse, + composeOrgName, + options, + resolverProviders, + params, + state, + }) { + const availableServices = this.graph.nodes() + for (const serviceName of serviceNames) { + if (!this.graph.hasNode(serviceName)) { + throw new ServerlessError( + `The service "${serviceName}" does not exist in the Compose configuration. Available services: ${availableServices.join(', ')}.`, + ServerlessErrorCodes.compose + .COMPOSE_GRAPH_SERVICE_DEPENDENCY_DOES_NOT_EXIST, + { stack: false }, + ) + } + } + // Delete the `service` option from the options object to pass through + // Framework schema validation (mirrors executeSingleComponent) + delete options.service + + const named = new Set(serviceNames) + // Capture node data and intra-set edges BEFORE any graph pruning + const nodeData = new Map( + serviceNames.map((name) => [name, this.graph.node(name)]), + ) + // Keep only direct edges between two named services. A dependency that runs + // THROUGH an unnamed (excluded) service has no in-run data path — the consumer + // reads the unnamed dep's frozen state via get-state, not the currently-running + // service — so there is no ordering to preserve and the two run in parallel. + const intraSetEdges = this.graph + .edges() + .filter((edge) => named.has(edge.v) && named.has(edge.w)) + + if (command[0] !== 'remove') { + // Get-state the FULL transitive closure of the named set — do NOT subtract + // named services. An unnamed dep can reference a NAMED service, so that named + // service must be in the read pass for the dep's `${param:}` to resolve (C1). + // Reading a named service's existing state is harmless: it is redeployed fresh + // in the real run, and the exact-set guarantee (never deploy/remove an unnamed + // service) is enforced by the rebuilt RUN graph below, not by pruning the read. + const depsToFetch = new Set() + for (const serviceName of serviceNames) { + for (const dep of this.getServiceDependencies(serviceName)) { + depsToFetch.add(dep) + } + } + const nodesToRemove = this.graph + .nodes() + .filter((node) => !depsToFetch.has(node)) + for (const node of nodesToRemove) { + this.graph.removeNode(node) + } + // The get-state pass runs with isMultipleComponents defaulting to true, so a + // dependency that is not deployed yet does NOT abort the run: getServiceUniqueId + // throws `Stack ... does not exist` for an absent stack, and that read failure + // is tolerated here (the real run then teaches the correct "deploy it first" + // message). This matches executeSingleComponent's get-state pass — the sibling + // whose full-closure shape this method mirrors. + await this.executeComponentsGraph({ + command: ['get-state'], + reverse, + composeOrgName, + options, + resolverProviders, + params, + runnerFunction: resolveConfigAndGetState, + state, + }) + } + + // Rebuild the graph with exactly the named services, keeping the edges + // among them so the run stays dependency-ordered + this.graph = new Graph() + for (const [name, data] of nodeData) { + this.graph.setNode(name, data) + } + for (const edge of intraSetEdges) { + this.graph.setEdge(edge.v, edge.w) + } + + await this.executeComponentsGraph({ + command, + reverse, + composeOrgName, + options, + resolverProviders, + params, + state, + isMultipleComponents: true, + }) + } + /** * Get all dependencies for a service, including transitive dependencies. * diff --git a/packages/sf-core/src/lib/runners/compose/state.js b/packages/sf-core/src/lib/runners/compose/state.js index a7b0884889f..d53d245d7d5 100644 --- a/packages/sf-core/src/lib/runners/compose/state.js +++ b/packages/sf-core/src/lib/runners/compose/state.js @@ -28,7 +28,22 @@ export const resolveConfigAndGetState = async ({ options, compose, }) - const { serviceUniqueId } = await runner.getServiceUniqueId() + let serviceUniqueId + try { + ;({ serviceUniqueId } = await runner.getServiceUniqueId()) + } catch (err) { + // A not-yet-deployed dependency has no stack — during a get-state pass + // getServiceUniqueId throws a plain `Stack does not exist`. That is + // the expected "no state" case, not a failure, so treat it like empty state + // and return undefined. Mirror the tolerance cf.js uses for CloudFormation + // ("does not exist" ValidationError -> null). Re-throw anything else so real + // errors (throttling, auth, etc.) are not swallowed. + if (err?.message?.includes('does not exist')) { + logger.debug('No state found (stack does not exist)') + return + } + throw err + } logger.debug( `Fetching state for service: ${serviceUniqueId} of type ${runner.constructor.runnerType}`, ) diff --git a/packages/sf-core/tests/integration/compose-dev/compose-dev.test.js b/packages/sf-core/tests/integration/compose-dev/compose-dev.test.js new file mode 100644 index 00000000000..0783c227cda --- /dev/null +++ b/packages/sf-core/tests/integration/compose-dev/compose-dev.test.js @@ -0,0 +1,227 @@ +import fs from 'fs' +import path from 'path' +import url from 'url' +import { spawn } from 'child_process' +import { setGlobalRendererSettings } from '@serverless/util' +import { jest } from '@jest/globals' +import { getTestStageName, runSfCore } from '../../utils/runSfCore.js' + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) +const SF_CORE_BIN = path.resolve(__dirname, '../../../bin/sf-core.js') + +jest.setTimeout(900000) + +/** + * Pull the live HTTP endpoint out of the dev-mode output. + * + * The dev runner prints an "Endpoints:" block (see `logOutputs` in + * plugins/aws/dev/index.js). A single httpApi endpoint can appear either as a + * "GET - https://..." line or as a bare execute-api URL, so accept both and + * make sure the resolved URL carries the route path (`/hello`). + */ +const extractEndpoint = (output, routePath) => { + const methodMatch = output.match( + /(?:GET|ANY|POST|PUT|PATCH|DELETE)\s*-\s*(https:\/\/\S+)/, + ) + const bareMatch = output.match(/(https:\/\/[^\s"']+execute-api[^\s"']*)/) + const raw = (methodMatch && methodMatch[1]) || (bareMatch && bareMatch[1]) + if (!raw) return null + const base = raw.replace(/\/+$/, '') + return base.endsWith(routePath) ? base : `${base}${routePath}` +} + +const fetchWithRetry = async (endpoint, attempts = 3, delayMs = 5000) => { + let lastErr + for (let i = 0; i < attempts; i++) { + try { + const response = await fetch(endpoint) + // A cold local-dev proxy can briefly answer 5xx before the shim is wired, + // and a freshly deployed API Gateway can briefly answer 403/404 while + // routes propagate — retry on any non-2xx. + if (!response.ok) { + lastErr = new Error(`endpoint returned ${response.status}`) + } else { + return response + } + } catch (err) { + lastErr = err + } + if (i < attempts - 1) { + await new Promise((resolve) => setTimeout(resolve, delayMs)) + } + } + throw lastErr +} + +describe('Compose single-service dev with Pattern D params', () => { + const configFileDirPath = path.join(__dirname, 'fixture') + const routePath = '/hello' + const originalEnv = { ...process.env } + let originalCwd + const sharedStage = getTestStageName() // stands in for the shared "dev" data stage + // Prefix (not suffix) so neither stage name is a substring of the other — + // otherwise a wrongly-resolved hoisted `aws:cf` param at the personal stage + // could still satisfy a `toContain` assertion meant to pin the shared stage. + const personalStage = `p${sharedStage}` // the developer's personal stage + let devProcess + let devOutput = '' + + beforeAll(async () => { + // Capture cwd before anything that can throw, so afterAll's restore never + // runs with `undefined` and masks the real setup error. + originalCwd = process.cwd() + // The dev command copies the pre-built dev-mode shim into the deployed + // function; it is a gitignored build artifact, so fresh checkouts/worktrees + // don't have it and `dev` fails at startup with BUILD_SHIM_FAILED. + const shimPath = path.resolve( + __dirname, + '../../../../serverless/lib/plugins/aws/dev/shim.min.js', + ) + if (!fs.existsSync(shimPath)) { + throw new Error( + `Dev-mode shim not built: ${shimPath} is missing. Build it once with 'npm run build:devmode:shim' in packages/serverless before running this test.`, + ) + } + process.chdir(configFileDirPath) + setGlobalRendererSettings({ isInteractive: false, logLevel: 'error' }) + // Mutate process.env in place. Reassigning it (process.env = {...}) does not + // reliably propagate to the native environment child processes inherit, and + // assigning `undefined` to a key stringifies it to "undefined" (truthy). + process.env.SERVERLESS_PLATFORM_STAGE = 'dev' + if (process.env.SERVERLESS_LICENSE_KEY_DEV) { + process.env.SERVERLESS_LICENSE_KEY = + process.env.SERVERLESS_LICENSE_KEY_DEV + } + delete process.env.SERVERLESS_ACCESS_KEY + process.env.COMPOSE_DEV_SHARED_STAGE = sharedStage + }) + + afterAll(async () => { + if (devProcess && !devProcess.killed && devProcess.exitCode === null) { + devProcess.kill('SIGINT') + await new Promise((resolve) => { + const t = setTimeout(() => { + try { + devProcess.kill('SIGKILL') + } catch { + // already gone + } + resolve() + }, 15000) + devProcess.on('exit', () => { + clearTimeout(t) + resolve() + }) + }) + } + // Teardown: personal-stage services (api self-provisioned by dev, plus + // localdep), then the shared one. + for (const [service, stage] of [ + ['api', personalStage], + ['localdep', personalStage], + ['shared', sharedStage], + ]) { + process.argv[2] = 'remove' + try { + await runSfCore({ + coreParams: { options: { stage, service }, command: ['remove'] }, + jest, + }) + } catch (err) { + // best-effort teardown; surface but don't mask the test result + console.error(`teardown of ${service}@${stage} failed:`, err.message) + } + } + // Restore cwd first: jest workers run several test files in one process, + // so a leaked chdir makes later, unrelated test files resolve THIS + // fixture's serverless-compose.yml (whose ${env:COMPOSE_DEV_SHARED_STAGE} + // is gone after the env restore below) from their own runs. + process.chdir(originalCwd) + // Restore the environment by mutating in place (see beforeAll). + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, originalEnv) + }) + + test('deploy the shared service at the shared stage and localdep at the personal stage', async () => { + process.argv[2] = 'deploy' + await runSfCore({ + coreParams: { + options: { stage: sharedStage, service: 'shared' }, + command: ['deploy'], + }, + jest, + }) + await runSfCore({ + coreParams: { + options: { stage: personalStage, service: 'localdep' }, + command: ['deploy'], + }, + jest, + }) + }) + + test('serverless api dev at the personal stage connects and serves local code with both params', async () => { + // The dev command is long-running, so it cannot be hosted by the in-process + // runSfCore helper — spawn the CLI directly. stdio:'pipe' (no TTY) keeps the + // dev-mode spinner from animating and flooding output. + devProcess = spawn( + process.execPath, + [SF_CORE_BIN, 'api', 'dev', '--stage', personalStage], + { cwd: configFileDirPath, env: { ...process.env }, stdio: 'pipe' }, + ) + + const endpoint = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error(`dev did not connect. Output:\n${devOutput}`)), + 600000, + ) + const onData = (chunk) => { + devOutput += chunk.toString() + // The endpoint block is printed before the "Connected" line; only + // resolve once both are present so the local shim is actually wired. + if (/Connected/.test(devOutput)) { + const endpoint = extractEndpoint(devOutput, routePath) + if (endpoint) { + clearTimeout(timer) + resolve(endpoint) + } + } + } + devProcess.stdout.on('data', onData) + devProcess.stderr.on('data', onData) + devProcess.on('exit', (code) => { + clearTimeout(timer) + reject(new Error(`dev exited early (${code}). Output:\n${devOutput}`)) + }) + devProcess.on('error', (err) => { + clearTimeout(timer) + reject( + new Error( + `dev failed to spawn: ${err.message}\nOutput:\n${devOutput}`, + ), + ) + }) + }) + + try { + const response = await fetchWithRetry(endpoint) + const body = await response.json() + + expect(body.marker).toEqual('local-dev-mode-code') + // graph param resolved at the personal stage + expect(body.localTopicArn).toContain( + `compose-devtest-localdep-${personalStage}`, + ) + // hoisted stage-mapped aws:cf resolved at the shared stage + expect(body.sharedTopicArn).toContain( + `compose-devtest-shared-${sharedStage}`, + ) + } catch (err) { + // Capture everything before afterAll tears the stacks down. + console.error('=== dev-mode child output ===') + console.error(devOutput) + console.error('=== assertion failure ===', err.message) + throw err + } + }) +}) diff --git a/packages/sf-core/tests/integration/compose-dev/fixture/api/handler.js b/packages/sf-core/tests/integration/compose-dev/fixture/api/handler.js new file mode 100644 index 00000000000..e520b0ed352 --- /dev/null +++ b/packages/sf-core/tests/integration/compose-dev/fixture/api/handler.js @@ -0,0 +1,8 @@ +export const hello = async () => ({ + statusCode: 200, + body: JSON.stringify({ + marker: 'local-dev-mode-code', + localTopicArn: process.env.LOCAL_TOPIC_ARN, + sharedTopicArn: process.env.SHARED_TOPIC_ARN, + }), +}) diff --git a/packages/sf-core/tests/integration/compose-dev/fixture/api/serverless.yml b/packages/sf-core/tests/integration/compose-dev/fixture/api/serverless.yml new file mode 100644 index 00000000000..7f31f0735b8 --- /dev/null +++ b/packages/sf-core/tests/integration/compose-dev/fixture/api/serverless.yml @@ -0,0 +1,13 @@ +service: compose-devtest-api +provider: + name: aws + region: us-east-1 + runtime: nodejs24.x + environment: + LOCAL_TOPIC_ARN: ${param:localTopicArn} + SHARED_TOPIC_ARN: ${param:sharedTopicArn} +functions: + hello: + handler: handler.hello + events: + - httpApi: GET /hello diff --git a/packages/sf-core/tests/integration/compose-dev/fixture/localdep/serverless.yml b/packages/sf-core/tests/integration/compose-dev/fixture/localdep/serverless.yml new file mode 100644 index 00000000000..66fa3002d7e --- /dev/null +++ b/packages/sf-core/tests/integration/compose-dev/fixture/localdep/serverless.yml @@ -0,0 +1,11 @@ +service: compose-devtest-localdep +provider: + name: aws + region: us-east-1 +resources: + Resources: + Topic: + Type: AWS::SNS::Topic + Outputs: + TopicArn: + Value: !Ref Topic diff --git a/packages/sf-core/tests/integration/compose-dev/fixture/serverless-compose.yml b/packages/sf-core/tests/integration/compose-dev/fixture/serverless-compose.yml new file mode 100644 index 00000000000..77cde79e1e7 --- /dev/null +++ b/packages/sf-core/tests/integration/compose-dev/fixture/serverless-compose.yml @@ -0,0 +1,18 @@ +stages: + default: + params: + dataStage: ${env:COMPOSE_DEV_SHARED_STAGE} +services: + shared: + path: shared + localdep: + path: localdep + api: + path: api + params: + localTopicArn: ${localdep.TopicArn} + # Default lets the compose config load during the bootstrap deploys of + # `shared`/`localdep` (Compose resolves every service's framework vars up + # front, before the shared stack exists). Once `shared` is deployed the + # aws:cf lookup wins, so `api dev` still receives the real shared-stage ARN. + sharedTopicArn: ${aws:cf:compose-devtest-shared-${param:dataStage}.TopicArn, 'pending'} diff --git a/packages/sf-core/tests/integration/compose-dev/fixture/shared/serverless.yml b/packages/sf-core/tests/integration/compose-dev/fixture/shared/serverless.yml new file mode 100644 index 00000000000..0051730ded1 --- /dev/null +++ b/packages/sf-core/tests/integration/compose-dev/fixture/shared/serverless.yml @@ -0,0 +1,11 @@ +service: compose-devtest-shared +provider: + name: aws + region: us-east-1 +resources: + Resources: + Topic: + Type: AWS::SNS::Topic + Outputs: + TopicArn: + Value: !Ref Topic diff --git a/packages/sf-core/tests/integration/compose-service-subset/compose-service-subset.test.js b/packages/sf-core/tests/integration/compose-service-subset/compose-service-subset.test.js new file mode 100644 index 00000000000..77b716b5172 --- /dev/null +++ b/packages/sf-core/tests/integration/compose-service-subset/compose-service-subset.test.js @@ -0,0 +1,332 @@ +import path from 'path' +import url from 'url' +import { setGlobalRendererSettings } from '@serverless/util' +import { + CloudFormationClient, + DescribeStacksCommand, + DeleteStackCommand, + waitUntilStackDeleteComplete, +} from '@aws-sdk/client-cloudformation' +import { jest } from '@jest/globals' +import { getTestStageName, runSfCore } from '../../utils/runSfCore.js' + +const __dirname = url.fileURLToPath(new URL('.', import.meta.url)) + +// Only a missing stack is an ignorable outcome for existence checks and +// teardown — anything else (credentials, throttling, DELETE_FAILED) must +// surface, or a real problem hides behind a silent catch. +const isStackMissingError = (err) => + err?.name === 'ValidationError' && /does not exist/.test(err?.message ?? '') + +describe('Compose --service subset (exact-set semantics)', () => { + const configFileDirPath = path.join(__dirname, 'fixture') + const cloudformationClient = new CloudFormationClient({ region: 'us-east-1' }) + const originalEnv = { ...process.env } + let originalCwd + const stage = getTestStageName() + // The fresh stage used by the teaching-message test below; hoisted so + // teardown can clean up the api stack if that test's expected failure + // ever regresses into a real deploy. + const freshStage = `${stage}x` + const stackName = (svc, atStage = stage) => `compose-subset-${svc}-${atStage}` + + const describeStack = async (svc) => { + const res = await cloudformationClient.send( + new DescribeStacksCommand({ StackName: stackName(svc) }), + ) + return res.Stacks[0] + } + const stackExists = async (svc) => { + try { + await describeStack(svc) + return true + } catch (err) { + if (isStackMissingError(err)) { + return false + } + throw err + } + } + + beforeAll(async () => { + originalCwd = process.cwd() + process.chdir(configFileDirPath) + setGlobalRendererSettings({ isInteractive: false, logLevel: 'error' }) + // Mutate process.env in place (reassigning it does not reliably propagate, + // and `undefined` values stringify to "undefined"). + process.env.SERVERLESS_PLATFORM_STAGE = 'dev' + if (process.env.SERVERLESS_LICENSE_KEY_DEV) { + process.env.SERVERLESS_LICENSE_KEY = + process.env.SERVERLESS_LICENSE_KEY_DEV + } + delete process.env.SERVERLESS_ACCESS_KEY + }) + + afterAll(async () => { + // Restore cwd first: jest workers run several test files in one process, + // so a leaked chdir makes later, unrelated test files resolve THIS + // fixture's serverless-compose.yml from their runs. + process.chdir(originalCwd) + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, originalEnv) + // Belt-and-suspenders teardown: force-delete every stack this suite might + // have created (including api at the teaching-test's fresh stage). Missing + // stacks are fine; any other failure is reported after cleanup finishes. + const stacks = [ + ...['api', 'middle', 'worker', 'db'].map((svc) => stackName(svc)), + stackName('api', freshStage), + ] + const failures = [] + for (const name of stacks) { + try { + await cloudformationClient.send( + new DeleteStackCommand({ StackName: name }), + ) + } catch (err) { + if (!isStackMissingError(err)) { + failures.push(`delete ${name}: ${err.message}`) + } + } + } + for (const name of stacks) { + try { + await waitUntilStackDeleteComplete( + { client: cloudformationClient, maxWaitTime: 300 }, + { StackName: name }, + ) + } catch (err) { + if (!isStackMissingError(err)) { + failures.push(`wait ${name}: ${err.message}`) + } + } + } + if (failures.length > 0) { + throw new Error(`Teardown failed:\n${failures.join('\n')}`) + } + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + // The fixture graph is api -> middle -> worker. `middle` is the unnamed, + // param-consuming intermediate that references the named `worker`; `api` + // references `middle`. `db` is unrelated. We deploy ONLY middle + worker up + // front (NOT api), so the later `deploy --service=api,worker` deploys `api` + // for the FIRST time — a genuine pass/fail signal for C1 (a false resolution + // would leave no api stack at all, not a stale one). + let middleStackBefore + + test('deploy --service=middle,worker deploys the intermediate + its dep (api,db untouched)', async () => { + process.argv[2] = 'deploy' + await runSfCore({ + coreParams: { + options: { stage, service: 'middle,worker' }, + command: ['deploy'], + }, + jest, + }) + for (const svc of ['middle', 'worker']) { + const stack = await describeStack(svc) + expect(stack.StackStatus).toMatch(/COMPLETE$/) + } + // api and db were not part of the named set -> not deployed + expect(await stackExists('api')).toBe(false) + expect(await stackExists('db')).toBe(false) + middleStackBefore = await describeStack('middle') + }, 300000) + + // C1 core: `deploy --service=api,worker`. `api` is deployed for the first + // time and resolves `${middle.PassedArn}` — where `middle` is UNNAMED and only + // get-state'd, and middle itself references the NAMED `worker`. Before the fix, + // worker was excluded from the get-state closure, so middle's `${worker.TopicArn}` + // could not resolve during get-state, middle's state was never loaded, and api's + // deploy failed (no api stack would exist). A COMPLETE api stack proves the fix. + test('deploy --service=api,worker deploys api fresh through the get-state closure; middle untouched', async () => { + process.argv[2] = 'deploy' + await runSfCore({ + coreParams: { + options: { stage, service: 'api,worker' }, + command: ['deploy'], + }, + jest, + }) + + // api was created (would be absent if middle->worker had not resolved) + const apiStack = await describeStack('api') + expect(apiStack.StackStatus).toMatch(/COMPLETE$/) + // worker still there + const workerStack = await describeStack('worker') + expect(workerStack.StackStatus).toMatch(/COMPLETE$/) + // middle (unnamed) was neither redeployed nor changed: same stack, no update + const middleStackAfter = await describeStack('middle') + expect(middleStackAfter.StackId).toEqual(middleStackBefore.StackId) + expect(middleStackAfter.LastUpdatedTime).toEqual( + middleStackBefore.LastUpdatedTime, + ) + // db still not deployed + expect(await stackExists('db')).toBe(false) + }, 300000) + + test('api resolved the compose param transitively from worker (api -> middle -> worker)', async () => { + const stack = await describeStack('api') + const stored = stack.Outputs.find( + (o) => o.OutputKey === 'StoredValue', + ).OutputValue + expect(stored).toMatch(/^arn:aws:sns:/) + expect(stored).toContain('compose-subset-worker') + }) + + test('deploy --service=api at a fresh stage fails with the teaching message (names middle)', async () => { + process.argv[2] = 'deploy' + await expect( + runSfCore({ + coreParams: { + options: { stage: freshStage, service: 'api' }, + command: ['deploy'], + }, + jest, + expectError: true, + }), + ).rejects.toThrow( + new RegExp(`serverless deploy --service=middle --stage ${freshStage}`), + ) + }, 120000) + + test('remove --service=api,worker removes exactly those two (middle stays)', async () => { + process.argv[2] = 'remove' + await runSfCore({ + coreParams: { + options: { stage, service: 'api,worker' }, + command: ['remove'], + }, + jest, + }) + expect(await stackExists('api')).toBe(false) + expect(await stackExists('worker')).toBe(false) + // middle was not in the named set, so it is still deployed + const middleStack = await describeStack('middle') + expect(middleStack.StackStatus).toMatch(/COMPLETE$/) + }, 300000) +}) + +// Regression: a fully-successful FIRST-RUN subset must exit 0. On a brand-new +// stage nothing is deployed, so the get-state pass over the dependency closure +// reads a not-yet-deployed stack. getServiceUniqueId throws "Stack ... does not +// exist" for that absent stack; before the fix that propagated into the per-node +// catch, which set process.exitCode = 1 and printed a bogus "✖ / 1 failed" even +// though the real run fully succeeded. `middle,worker` is self-contained (middle +// depends only on worker, worker has no deps), so the real run deploys both and +// succeeds — the only failure signal is the spurious exit code / report. +describe('Compose --service subset first run exits 0 (regression)', () => { + const configFileDirPath = path.join(__dirname, 'fixture') + const cloudformationClient = new CloudFormationClient({ region: 'us-east-1' }) + const originalEnv = { ...process.env } + let originalCwd + // Distinct fresh stage: nothing here is pre-deployed by the suite above. + const stage = `${getTestStageName()}fr` + const stackName = (svc) => `compose-subset-${svc}-${stage}` + + const stripAnsi = (s) => + // eslint-disable-next-line no-control-regex + s.replace(/\[[0-9;]*m/g, '') + + beforeAll(async () => { + originalCwd = process.cwd() + process.chdir(configFileDirPath) + setGlobalRendererSettings({ isInteractive: false, logLevel: 'error' }) + // Mutate process.env in place (see the suite above). + process.env.SERVERLESS_PLATFORM_STAGE = 'dev' + if (process.env.SERVERLESS_LICENSE_KEY_DEV) { + process.env.SERVERLESS_LICENSE_KEY = + process.env.SERVERLESS_LICENSE_KEY_DEV + } + delete process.env.SERVERLESS_ACCESS_KEY + }) + + afterAll(async () => { + // Restore cwd + env in place (see the suite above). + process.chdir(originalCwd) + for (const key of Object.keys(process.env)) delete process.env[key] + Object.assign(process.env, originalEnv) + // Missing stacks are fine; any other failure is reported after cleanup. + const failures = [] + for (const svc of ['middle', 'worker']) { + try { + await cloudformationClient.send( + new DeleteStackCommand({ StackName: stackName(svc) }), + ) + } catch (err) { + if (!isStackMissingError(err)) { + failures.push(`delete ${stackName(svc)}: ${err.message}`) + } + } + } + for (const svc of ['middle', 'worker']) { + try { + await waitUntilStackDeleteComplete( + { client: cloudformationClient, maxWaitTime: 300 }, + { StackName: stackName(svc) }, + ) + } catch (err) { + if (!isStackMissingError(err)) { + failures.push(`wait ${stackName(svc)}: ${err.message}`) + } + } + } + if (failures.length > 0) { + throw new Error(`Teardown failed:\n${failures.join('\n')}`) + } + }) + + afterEach(() => { + jest.restoreAllMocks() + }) + + test('deploy --service=middle,worker on a fresh stage succeeds cleanly (exit code not 1, 0 failed)', async () => { + process.argv[2] = 'deploy' + + let stderrOut = '' + const stderrSpy = jest + .spyOn(process.stderr, 'write') + .mockImplementation((chunk) => { + stderrOut += chunk.toString() + return true + }) + + // Capture and neutralize process.exitCode around the run so the bug's + // lingering exit code neither escapes into Jest nor false-passes here. + const prevExitCode = process.exitCode + process.exitCode = 0 + let observedExitCode + try { + await runSfCore({ + coreParams: { + options: { stage, service: 'middle,worker' }, + command: ['deploy'], + }, + jest, + }) + } finally { + observedExitCode = process.exitCode + process.exitCode = prevExitCode + stderrSpy.mockRestore() + } + + const output = stripAnsi(stderrOut) + + // Primary signal: the run must not exit with a failure code. + expect(observedExitCode).not.toBe(1) + // The report must show zero failures and no failure marker. + expect(output).not.toContain('✖') + expect(output).toContain('0 failed') + + // Sanity: both services were actually deployed by the real run. + for (const svc of ['middle', 'worker']) { + const res = await cloudformationClient.send( + new DescribeStacksCommand({ StackName: stackName(svc) }), + ) + expect(res.Stacks[0].StackStatus).toMatch(/COMPLETE$/) + } + }, 300000) +}) diff --git a/packages/sf-core/tests/integration/compose-service-subset/fixture/api/serverless.yml b/packages/sf-core/tests/integration/compose-service-subset/fixture/api/serverless.yml new file mode 100644 index 00000000000..e71644bf894 --- /dev/null +++ b/packages/sf-core/tests/integration/compose-service-subset/fixture/api/serverless.yml @@ -0,0 +1,14 @@ +service: compose-subset-api +provider: + name: aws + region: us-east-1 +resources: + Resources: + Param: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: ${param:middleArn} + Outputs: + StoredValue: + Value: ${param:middleArn} diff --git a/packages/sf-core/tests/integration/compose-service-subset/fixture/db/serverless.yml b/packages/sf-core/tests/integration/compose-service-subset/fixture/db/serverless.yml new file mode 100644 index 00000000000..e06630743ae --- /dev/null +++ b/packages/sf-core/tests/integration/compose-service-subset/fixture/db/serverless.yml @@ -0,0 +1,11 @@ +service: compose-subset-db +provider: + name: aws + region: us-east-1 +resources: + Resources: + Topic: + Type: AWS::SNS::Topic + Outputs: + TopicArn: + Value: !Ref Topic diff --git a/packages/sf-core/tests/integration/compose-service-subset/fixture/middle/serverless.yml b/packages/sf-core/tests/integration/compose-service-subset/fixture/middle/serverless.yml new file mode 100644 index 00000000000..71463f60ec8 --- /dev/null +++ b/packages/sf-core/tests/integration/compose-service-subset/fixture/middle/serverless.yml @@ -0,0 +1,16 @@ +service: compose-subset-middle +provider: + name: aws + region: us-east-1 +resources: + Resources: + Topic: + Type: AWS::SNS::Topic + Param: + Type: AWS::SSM::Parameter + Properties: + Type: String + Value: ${param:workerTopicArn} + Outputs: + PassedArn: + Value: ${param:workerTopicArn} diff --git a/packages/sf-core/tests/integration/compose-service-subset/fixture/serverless-compose.yml b/packages/sf-core/tests/integration/compose-service-subset/fixture/serverless-compose.yml new file mode 100644 index 00000000000..38f7f962109 --- /dev/null +++ b/packages/sf-core/tests/integration/compose-service-subset/fixture/serverless-compose.yml @@ -0,0 +1,13 @@ +services: + db: + path: db + worker: + path: worker + middle: + path: middle + params: + workerTopicArn: ${worker.TopicArn} + api: + path: api + params: + middleArn: ${middle.PassedArn} diff --git a/packages/sf-core/tests/integration/compose-service-subset/fixture/worker/serverless.yml b/packages/sf-core/tests/integration/compose-service-subset/fixture/worker/serverless.yml new file mode 100644 index 00000000000..47de4996f5f --- /dev/null +++ b/packages/sf-core/tests/integration/compose-service-subset/fixture/worker/serverless.yml @@ -0,0 +1,11 @@ +service: compose-subset-worker +provider: + name: aws + region: us-east-1 +resources: + Resources: + Topic: + Type: AWS::SNS::Topic + Outputs: + TopicArn: + Value: !Ref Topic diff --git a/packages/sf-core/tests/unit/lib/runners/compose/execute-subset.test.js b/packages/sf-core/tests/unit/lib/runners/compose/execute-subset.test.js new file mode 100644 index 00000000000..81cc9aa11c3 --- /dev/null +++ b/packages/sf-core/tests/unit/lib/runners/compose/execute-subset.test.js @@ -0,0 +1,150 @@ +import { jest } from '@jest/globals' +import { parseComposeGraph } from '../../../../../src/lib/runners/compose/index.js' + +// Chain: api -> worker -> db (via compose graph params); "standalone" has no edges +// (stands in for an always-on service reached via hoisted aws:cf — no graph dep). +const CONFIG = { + services: { + db: { path: 'db' }, + worker: { path: 'worker', params: { dbRef: '${db.DbOut}' } }, + api: { path: 'api', params: { queueUrl: '${worker.QueueOut}' } }, + standalone: { path: 'standalone' }, + }, +} + +const buildCompose = async () => + await parseComposeGraph({ + servicePath: '/tmp/compose-subset-test', + configuration: JSON.parse(JSON.stringify(CONFIG)), + versions: {}, + }) + +const baseArgs = (overrides = {}) => ({ + serviceNames: ['api', 'worker'], + command: ['deploy'], + reverse: false, + composeOrgName: 'test-org', + options: { service: 'api,worker' }, + resolverProviders: {}, + params: {}, + state: { localState: {} }, + ...overrides, +}) + +describe('Compose#executeSubsetComponents', () => { + test('throws on unknown service, listing available services', async () => { + const compose = await buildCompose() + compose.executeComponentsGraph = jest.fn() + // Available-services list follows graph insertion order (config key order) + await expect( + compose.executeSubsetComponents( + baseArgs({ serviceNames: ['api', 'nope'] }), + ), + ).rejects.toThrow(/nope.*does not exist.*db.*worker.*api.*standalone/s) + expect(compose.executeComponentsGraph).not.toHaveBeenCalled() + }) + + test('deploy: get-state over the FULL dependency closure, then runs exactly the named set with intra-set edges', async () => { + const compose = await buildCompose() + const calls = [] + compose.executeComponentsGraph = jest.fn(async (args) => { + calls.push({ command: args.command, nodes: [...compose.graph.nodes()] }) + }) + + await compose.executeSubsetComponents(baseArgs()) + + // Call 1: get-state over the full closure of the named set (api deps: worker, + // db; worker deps: db). Named services stay IN the read pass so an unnamed dep + // referencing a named service can still resolve (C1). Reading a named service's + // state is harmless — it is redeployed fresh in the real run. + expect(calls[0].command).toEqual(['get-state']) + expect(calls[0].nodes.sort()).toEqual(['db', 'worker']) + // Call 2: the real command over exactly the named set + expect(calls[1].command).toEqual(['deploy']) + expect(calls[1].nodes.sort()).toEqual(['api', 'worker']) + // Intra-set edge preserved (api -> worker) so ordering holds + expect(compose.graph.edges()).toEqual([{ v: 'api', w: 'worker' }]) + // isMultipleComponents on the real run + expect( + compose.executeComponentsGraph.mock.calls[1][0].isMultipleComponents, + ).toBe(true) + }) + + test('C1: get-state closure includes a NAMED service that an unnamed dep references', async () => { + // Chain api -> middle -> worker; middle (unnamed) references worker (named). + // The get-state pass must include worker so middle's `${worker.Out}` resolves; + // excluding named services (the C1 bug) leaves worker out and the read fails. + const c1Config = { + services: { + worker: { path: 'worker' }, + middle: { path: 'middle', params: { w: '${worker.Out}' } }, + api: { path: 'api', params: { m: '${middle.Out}' } }, + }, + } + const compose = await parseComposeGraph({ + servicePath: '/tmp/compose-subset-test', + configuration: JSON.parse(JSON.stringify(c1Config)), + versions: {}, + }) + const calls = [] + compose.executeComponentsGraph = jest.fn(async (args) => { + calls.push({ command: args.command, nodes: [...compose.graph.nodes()] }) + }) + + await compose.executeSubsetComponents( + baseArgs({ + serviceNames: ['api', 'worker'], + options: { service: 'api,worker' }, + }), + ) + + // get-state pass runs over the full closure: the unnamed intermediate `middle` + // AND the named `worker` it references (not just `middle`). + expect(calls[0].command).toEqual(['get-state']) + expect(calls[0].nodes.sort()).toEqual(['middle', 'worker']) + // Real run is still exactly the named set. + expect(calls[1].command).toEqual(['deploy']) + expect(calls[1].nodes.sort()).toEqual(['api', 'worker']) + }) + + test('remove: no get-state pass, runs exactly the named set', async () => { + const compose = await buildCompose() + compose.executeComponentsGraph = jest.fn() + await compose.executeSubsetComponents( + baseArgs({ command: ['remove'], serviceNames: ['api', 'worker'] }), + ) + expect(compose.executeComponentsGraph).toHaveBeenCalledTimes(1) + expect(compose.executeComponentsGraph.mock.calls[0][0].command).toEqual([ + 'remove', + ]) + expect(compose.graph.nodes().sort()).toEqual(['api', 'worker']) + }) + + test('deletes options.service so framework schema validation passes downstream', async () => { + const compose = await buildCompose() + compose.executeComponentsGraph = jest.fn() + const options = { service: 'api,worker', stage: 'alice' } + await compose.executeSubsetComponents(baseArgs({ options })) + expect(options.service).toBeUndefined() + }) + + test('named set that is its own closure: get-state pass reads the named deps, then runs them', async () => { + const compose = await buildCompose() + const calls = [] + compose.executeComponentsGraph = jest.fn(async (args) => { + calls.push({ command: args.command, nodes: [...compose.graph.nodes()] }) + }) + await compose.executeSubsetComponents( + baseArgs({ + serviceNames: ['worker', 'db'], + options: { service: 'worker,db' }, + }), + ) + // worker depends on db; both are named, so the closure is {db}. The read pass + // no longer excludes named services (C1), so db is get-state'd harmlessly. + expect(calls[0].command).toEqual(['get-state']) + expect(calls[0].nodes.sort()).toEqual(['db']) + expect(calls[1].nodes.sort()).toEqual(['db', 'worker']) + expect(compose.graph.edges()).toEqual([{ v: 'worker', w: 'db' }]) + }) +}) diff --git a/packages/sf-core/tests/unit/lib/runners/compose/service-option.test.js b/packages/sf-core/tests/unit/lib/runners/compose/service-option.test.js new file mode 100644 index 00000000000..7419df7912a --- /dev/null +++ b/packages/sf-core/tests/unit/lib/runners/compose/service-option.test.js @@ -0,0 +1,27 @@ +// Importing router.js first resolves a pre-existing circular-import ordering +// issue (compose.js -> ./index.js -> router.js -> compose.js): when compose.js +// is the module graph's entry point, router.js's top-level `ComposeRunner` +// reference gets evaluated before compose.js finishes defining it. This +// side-effect import forces the safe evaluation order without altering the +// module under test. +import '../../../../../src/lib/router.js' +import { parseServiceOption } from '../../../../../src/lib/runners/compose/compose.js' + +describe('parseServiceOption', () => { + test('single name', () => { + expect(parseServiceOption('api')).toEqual(['api']) + }) + test('comma-separated list', () => { + expect(parseServiceOption('api,worker')).toEqual(['api', 'worker']) + }) + test('trims whitespace around names', () => { + expect(parseServiceOption(' api , worker ')).toEqual(['api', 'worker']) + }) + test('drops empty segments (trailing/double commas)', () => { + expect(parseServiceOption('api,,worker,')).toEqual(['api', 'worker']) + }) + test('undefined/empty -> empty array', () => { + expect(parseServiceOption(undefined)).toEqual([]) + expect(parseServiceOption('')).toEqual([]) + }) +}) diff --git a/packages/sf-core/tests/unit/lib/runners/compose/state.test.js b/packages/sf-core/tests/unit/lib/runners/compose/state.test.js new file mode 100644 index 00000000000..d60b02d664e --- /dev/null +++ b/packages/sf-core/tests/unit/lib/runners/compose/state.test.js @@ -0,0 +1,86 @@ +import { jest } from '@jest/globals' + +// Mock the router so we can inject a runner stub with a controlled +// getServiceUniqueId() behavior. +const mockGetRunner = jest.fn() +jest.unstable_mockModule('../../../../../src/lib/router.js', () => ({ + getRunner: mockGetRunner, +})) + +const { resolveConfigAndGetState } = + await import('../../../../../src/lib/runners/compose/state.js') + +// Minimal runner stub. `runnerType` is read via `runner.constructor.runnerType`, +// so it lives on the class as a static. +class FakeRunner { + static runnerType = 'aws' + constructor(getServiceUniqueId) { + this.getServiceUniqueId = getServiceUniqueId + } +} + +const callResolve = ({ getServiceUniqueId, getServiceState }) => { + mockGetRunner.mockResolvedValue({ + runner: new FakeRunner(getServiceUniqueId), + }) + return resolveConfigAndGetState({ + command: ['get-state'], + options: {}, + compose: {}, + state: { getServiceState }, + }) +} + +describe('compose/state resolveConfigAndGetState', () => { + afterEach(() => { + jest.clearAllMocks() + }) + + test('tolerates a not-yet-deployed dependency ("Stack ... does not exist") as no-state', async () => { + // A first-run subset get-state pass reads dependencies that are not deployed + // yet. getServiceUniqueId throws a plain "Stack does not exist" for an + // absent stack — that is the expected "no state" case, not a failure. + await expect( + callResolve({ + getServiceUniqueId: jest + .fn() + .mockRejectedValue(new Error('Stack foo-dev does not exist')), + getServiceState: jest.fn(), + }), + ).resolves.toBeUndefined() + }) + + test('re-throws any other error from getServiceUniqueId (does not swallow real failures)', async () => { + await expect( + callResolve({ + getServiceUniqueId: jest + .fn() + .mockRejectedValue(new Error('ThrottlingException: Rate exceeded')), + getServiceState: jest.fn(), + }), + ).rejects.toThrow('ThrottlingException: Rate exceeded') + }) + + test('returns undefined when the stack exists but no service state is stored', async () => { + await expect( + callResolve({ + getServiceUniqueId: jest + .fn() + .mockResolvedValue({ serviceUniqueId: 'foo-dev' }), + getServiceState: jest.fn().mockResolvedValue(undefined), + }), + ).resolves.toBeUndefined() + }) + + test('returns the fetched state when it exists', async () => { + const fetchedState = { outputs: { TopicArn: 'arn:aws:sns:...' } } + await expect( + callResolve({ + getServiceUniqueId: jest + .fn() + .mockResolvedValue({ serviceUniqueId: 'foo-dev' }), + getServiceState: jest.fn().mockResolvedValue(fetchedState), + }), + ).resolves.toEqual({ state: fetchedState }) + }) +}) diff --git a/packages/sf-core/tests/unit/lib/runners/compose/teaching-errors.test.js b/packages/sf-core/tests/unit/lib/runners/compose/teaching-errors.test.js new file mode 100644 index 00000000000..92706fd9b01 --- /dev/null +++ b/packages/sf-core/tests/unit/lib/runners/compose/teaching-errors.test.js @@ -0,0 +1,154 @@ +// Importing router.js first resolves a pre-existing circular-import ordering +// issue (compose.js -> ./index.js -> router.js -> compose.js): when compose.js +// is the module graph's entry point, router.js's top-level `ComposeRunner` +// reference gets evaluated before compose.js finishes defining it. This +// side-effect import forces the safe evaluation order without altering the +// module under test. +import '../../../../../src/lib/router.js' +import { jest } from '@jest/globals' +import { runCompose } from '../../../../../src/lib/runners/compose/compose.js' +import { parseComposeGraph } from '../../../../../src/lib/runners/compose/index.js' + +// runCompose validates the command BEFORE building the graph or touching the +// state store, so these tests need no resolverManager beyond a stub. +const runComposeArgs = (command, options = {}) => ({ + composeConfigFile: { + services: { api: { path: 'api' }, worker: { path: 'worker' } }, + }, + composeDirPath: '/tmp/compose-teaching-test', + versions: {}, + command, + options, + resolverManager: { getResolverProviders: () => ({}), params: {} }, +}) + +describe('compose teaching errors', () => { + test('project-level dev lists per-service dev commands', async () => { + await expect(runCompose(runComposeArgs(['dev']))).rejects.toThrow( + /runs on one service at a time[\s\S]*serverless api dev[\s\S]*serverless worker dev/, + ) + }) + + test('unsupported project-level command names supported commands and the per-service form', async () => { + await expect(runCompose(runComposeArgs(['logs']))).rejects.toThrow( + /Project-wide commands: deploy, info, remove, print, package[\s\S]*serverless logs/, + ) + }) + + test('a --service value that resolves to no names is rejected, not run whole-graph', async () => { + // `--service=,` / `--service=" "` are malformed: they must not silently + // fall through to a whole-graph run. + for (const service of [',', ' ', ' , ']) { + await expect( + runCompose(runComposeArgs(['deploy'], { service })), + ).rejects.toThrow(/No services were resolved from --service/) + } + }) + + test('unresolved compose param names the exact deploy command with stage', async () => { + const compose = await parseComposeGraph({ + servicePath: '/tmp/compose-teaching-test', + configuration: { + services: { + worker: { path: 'worker' }, + api: { path: 'api', params: { queueUrl: '${worker.QueueOut}' } }, + }, + }, + versions: {}, + }) + // Stub runner so the worker "runs" without producing state; api's param + // lookup against empty localState must then throw the teaching message. + const runnerFunction = jest.fn(async () => ({})) + await expect( + compose.executeComponentsGraph({ + command: ['deploy'], + reverse: false, + composeOrgName: 'test-org', + options: { stage: 'alice' }, + resolverProviders: {}, + params: {}, + runnerFunction, + state: { localState: {} }, + isMultipleComponents: false, // single-mode: error is rethrown, not swallowed into the report + }), + ).rejects.toThrow( + /Could not resolve[\s\S]*'queueUrl'[\s\S]*serverless deploy --service=worker --stage alice/, + ) + }) + + test('wrong output KEY (state present) names the bad output and lists available ones, not "deploy it first"', async () => { + // worker IS deployed and its state IS present, but api references a typo'd + // output name. The error must point at the output name, not tell the user to + // (re)deploy a service that is already there. + const compose = await parseComposeGraph({ + servicePath: '/tmp/compose-teaching-test', + configuration: { + services: { + worker: { path: 'worker' }, + api: { path: 'api', params: { queueUrl: '${worker.WrongKey}' } }, + }, + }, + versions: {}, + }) + // worker "runs" and reports a DIFFERENT output key, so localState.worker ends + // up present with outputs { RightKey } before api's param lookup runs. + const runnerFunction = jest.fn(async () => ({ + state: { outputs: { RightKey: 'v' } }, + })) + let thrown + try { + await compose.executeComponentsGraph({ + command: ['deploy'], + reverse: false, + composeOrgName: 'test-org', + options: { stage: 'alice' }, + resolverProviders: {}, + params: {}, + runnerFunction, + state: { localState: {} }, + isMultipleComponents: false, + }) + } catch (err) { + thrown = err + } + expect(thrown).toBeDefined() + expect(thrown.message).toContain("has no output 'WrongKey'") + expect(thrown.message).toContain('Available outputs: RightKey') + expect(thrown.message).not.toMatch(/deploy it first/i) + }) + + test('literal (hoisted, pre-resolved) params pass straight through to the service', async () => { + // A hoisted `${aws:cf:...}` param is already a literal string by the time + // the graph runs (compose-file variables resolve at startup) — pin the + // literal pass-through branch of compose param resolution (the `else` + // that copies non-graph params verbatim). + const compose = await parseComposeGraph({ + servicePath: '/tmp/compose-teaching-test', + configuration: { + services: { + api: { + path: 'api', + params: { sharedArn: 'arn:aws:sns:us-east-1:123:shared-dev-Topic' }, + }, + }, + }, + versions: {}, + }) + const runnerFunction = jest.fn(async () => ({})) + await compose.executeComponentsGraph({ + command: ['deploy'], + reverse: false, + composeOrgName: 'test-org', + options: {}, + resolverProviders: {}, + params: {}, + runnerFunction, + state: { localState: {} }, + isMultipleComponents: false, + }) + expect(runnerFunction).toHaveBeenCalledTimes(1) + expect(runnerFunction.mock.calls[0][0].compose.params.sharedArn).toEqual( + 'arn:aws:sns:us-east-1:123:shared-dev-Topic', + ) + }) +})