Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions docs/sf/guides/compose.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/sf-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/**",
Expand Down
101 changes: 88 additions & 13 deletions packages/sf-core/src/lib/runners/compose/compose.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <service> ${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=<service-a>,<service-b>.`,
ServerlessErrorCodes.compose.COMPOSE_COMMAND_NOT_SUPPORTED,
)
err.stack = undefined
Expand All @@ -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,
Expand All @@ -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
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (
getGlobalRendererSettings().logLevel === 'notice' ||
getGlobalRendererSettings().logLevel === 'info'
Expand All @@ -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) {
Expand All @@ -259,4 +334,4 @@ Docs: https://www.serverless.com/framework/docs/guides/compose
}
}

export { runCompose }
export { runCompose, parseServiceOption }
10 changes: 10 additions & 0 deletions packages/sf-core/src/lib/runners/compose/help.js
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,10 @@ const commands = [
command: 'print',
description: 'Print the configuration of all services',
},
{
command: 'package',
description: 'Package all services',
},
]

/**
Expand Down Expand Up @@ -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()
Expand Down
147 changes: 144 additions & 3 deletions packages/sf-core/src/lib/runners/compose/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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
})
}

Expand Down Expand Up @@ -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<string, any>} options
* @param {Record<string, any>} resolverProviders
* @param {Record<string, any>} params
* @param {State} state
* @returns {Promise<void>}
*/
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.
*
Expand Down
17 changes: 16 additions & 1 deletion packages/sf-core/src/lib/runners/compose/state.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 <name> 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}`,
)
Expand Down
Loading
Loading