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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,16 @@ history.

## [Unreleased]

### Added

- `comfy deploy up --startup-arg=<flag>` sets the ComfyUI startup flags on the
deployment it creates, and `comfy deploy scale --startup-arg=<flag>` changes
them on a stopped deployment (`--highvram`, `--reserve-vram 2`, and the rest
of the service's allowlist); `scale --clear-startup-args` removes them. Flags
apply when the deployment starts, so stop a running deployment first
(`comfy deploy stop`, then `scale`, then `start`); `deploy status`, `deploy up`
and `deploy scale` report the stored flags as `computeConfig.startupArgs`.

### Fixed

- `comfy install --fast-deps --nvidia` no longer installs a torch that its
Expand Down
49 changes: 47 additions & 2 deletions comfy_cli/command/deploy.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,38 @@
typer.Argument(help="ComfyUI install directory or build spec path. Default: the current directory."),
]
DeploymentOption = Annotated[str | None, typer.Option("--deployment", help="Select this deployment id.")]
StartupArgOption = Annotated[
list[str] | None,
typer.Option(
"--startup-arg",
help=(
"ComfyUI startup flag, one token per use, written as --startup-arg=--highvram "
"(a value is its own token: --startup-arg=--reserve-vram --startup-arg=2). "
"Only the service's allowlist of VRAM, precision, attention, cache and performance "
"flags is accepted. `up` sets the flags on the deployment it creates; `scale` replaces "
"the whole stored set on a stopped deployment. Flags apply when the deployment starts."
),
),
]


def _startup_args(values: list[str] | None) -> tuple[str, ...] | None:
"""``None`` when the flag was never given, so the request omits the field."""
return tuple(values) if values else None


def _refuse_startup_arg_conflict(renderer, startup_arg: list[str] | None, clear: bool) -> None:
"""``--startup-arg`` sets the flags and ``--clear-startup-args`` removes them;
both at once has no meaning the service could honor, so it is refused
before any round trip."""
if not startup_arg or not clear:
return
renderer.error(
code="deploy_conflicting_input",
message="--startup-arg and --clear-startup-args cannot be combined",
details={"conflicting": ["--startup-arg", "--clear-startup-args"]},
)
raise typer.Exit(code=1)


def _require_paired_bounds(renderer, minimum: int | None, maximum: int | None) -> None:
Expand Down Expand Up @@ -127,10 +159,21 @@ def scale_cmd(
] = None,
gpu: Annotated[str | None, typer.Option("--gpu", help="GPU class; deployment must be stopped.")] = None,
region: Annotated[str | None, typer.Option("--region", help="Region; deployment must be stopped.")] = None,
startup_arg: StartupArgOption = None,
clear_startup_args: Annotated[
bool,
typer.Option("--clear-startup-args", help="Remove every ComfyUI startup flag; deployment must be stopped."),
] = False,
) -> None:
_require_paired_bounds(get_renderer(), minimum, maximum)
renderer = get_renderer()
_require_paired_bounds(renderer, minimum, maximum)
_refuse_startup_arg_conflict(renderer, startup_arg, clear_startup_args)
target = _deploy_read.ReadRequest(path, deployment_id)
_deploy_lifecycle.run_scale(_deploy_lifecycle.ScaleRequest(target, minimum, maximum, gpu, region))
_deploy_lifecycle.run_scale(
_deploy_lifecycle.ScaleRequest(
target, minimum, maximum, gpu, region, _startup_args(startup_arg), clear_startup_args
)
)


@app.command("stop", help="Pause a deployment while retaining its endpoint and staged models.")
Expand Down Expand Up @@ -234,6 +277,7 @@ def up_cmd(
] = None,
release: Annotated[str | None, typer.Option("--release", help="Deploy this release id.")] = None,
deployment_id: DeploymentOption = None,
startup_arg: StartupArgOption = None,
watch: Annotated[bool, typer.Option("--watch", help="Poll until the deployment reaches a terminal state.")] = False,
) -> None:
renderer = get_renderer()
Expand All @@ -247,6 +291,7 @@ def up_cmd(
minimum=minimum,
maximum=maximum,
deployment_id=deployment_id,
startup_args=_startup_args(startup_arg),
)
try:
result = reconcile_up(builder, client, request)
Expand Down
25 changes: 20 additions & 5 deletions comfy_cli/command/deploy_lifecycle.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ class ScaleRequest:
maximum: int | None
gpu: str | None
region: str | None
# ``None`` is "flag omitted", and the field is then left out of the request
# so the service keeps the stored flags; an explicit clear sends an empty
# list, which is how the service reads "remove them".
startup_args: tuple[str, ...] | None = None
clear_startup_args: bool = False


@runtime_checkable
Expand Down Expand Up @@ -87,6 +92,10 @@ def scale(renderer: Renderer, client: DeploymentLifecycleClient, deployment_id:
value = requested if requested is not None else current.get(bound)
if value is not None:
merged[bound] = value
if request.clear_startup_args:
merged["startupArgs"] = []
elif request.startup_args is not None:
merged["startupArgs"] = list(request.startup_args)
try:
result = client.update_deployment(deployment_id, merged)
except DeployAPIError as error:
Expand All @@ -100,11 +109,17 @@ def scale(renderer: Renderer, client: DeploymentLifecycleClient, deployment_id:
details=details,
) from error
if renderer.is_pretty():
renderer.success(
f"Scaled deployment {deployment_id} to "
f"min={merged.get('min', 'unset')}, max={merged.get('max', 'unset')}"
)
renderer.emit(result, command="deploy scale", changed=merged != current)
settings = [f"{bound}={merged[bound]}" for bound in ("min", "max") if bound in merged]
if "startupArgs" in merged:
settings.append(f"startupArgs={' '.join(merged['startupArgs']) or 'none'}")
elif not settings:
settings = ["min=unset", "max=unset"]
renderer.success(f"Scaled deployment {deployment_id} to {', '.join(settings)}")
# Overlaid on the stored config: a field the request leaves out is kept
# by the service, so it is not a change either. The service stores no
# empty flag set, so a clear against none stored is not a change.
before = {**current, "startupArgs": current.get("startupArgs", [])}
renderer.emit(result, command="deploy scale", changed={**before, **merged} != before)

_run_lifecycle(request.target, scale)

Expand Down
23 changes: 19 additions & 4 deletions comfy_cli/command/deploy_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@ class UpRequest:
# The deployment `--deployment` named, when the Build has more than one the
# ranking cannot separate.
deployment_id: str | None = None
# ComfyUI startup flags, one token each. ``None`` is "flag omitted": the
# service keeps whatever the deployment stores, so only an explicit list is
# ever sent, and only a create applies it (see ``reconcile_up``).
startup_args: tuple[str, ...] | None = None


@dataclass(frozen=True, slots=True)
Expand All @@ -49,10 +53,10 @@ class UpResult:
created: bool
changed: bool
# Flags the caller supplied that this reconcile could not apply. Restarting
# a stopped deployment is a start, not an edit, so bounds passed alongside
# it are droppedsilently discarding explicit input is the same defect as
# silently resetting it, so the renderer says so.
dropped_bounds: tuple[str, ...] = ()
# a stopped deployment is a start, not an edit, so bounds and startup flags
# passed alongside it are dropped; silently discarding explicit input is the
# same defect as silently resetting it, so the renderer says so.
dropped_flags: tuple[str, ...] = ()

def payload(self) -> JsonObject:
supersedes: list[JsonValue] = [*self.supersedes]
Expand Down Expand Up @@ -91,6 +95,13 @@ def required_int(value: JsonObject, key: str) -> int:
return field


def required_string_list(value: JsonObject, key: str) -> list[str]:
field = value.get(key)
if not isinstance(field, list) or any(not isinstance(item, str) or not item.strip() for item in field):
raise server_shape_error(f"the deploy service returned an invalid {key}", field=key)
return list(field)


def compute_config(deployment: JsonObject) -> JsonObject:
"""The deployment's compute configuration, as the service models it.

Expand All @@ -109,6 +120,10 @@ def compute_config(deployment: JsonObject) -> JsonObject:
for bound in ("min", "max"):
if bound in raw:
config[bound] = required_int(raw, bound)
# Stored only when the deployment has flags: the service drops the key on a
# clear, so absence here is "none" and an omitted flag on `scale` keeps it.
if "startupArgs" in raw:
config["startupArgs"] = required_string_list(raw, "startupArgs")
return config


Expand Down
76 changes: 61 additions & 15 deletions comfy_cli/command/deploy_up.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,15 +119,39 @@ def _create_live_deployment(client: DeployUpClient, request: UpRequest, compute:
raise AssertionError("bounded create loop exhausted without returning or raising")


def _dropped_bounds(request: UpRequest, compute: JsonObject) -> tuple[str, ...]:
"""Name the bound flags that were supplied but would not change the live value.
def _dropped_flags(request: UpRequest, compute: JsonObject) -> tuple[str, ...]:
"""Name the flags the caller supplied that a restart will not apply.

Only the restart branches consult this: they hand back ``compute`` untouched,
so a bound the caller actually typed is discarded. A bound equal to what is
already live is not reported — nothing was lost.
so a bound that differs from the live value is discarded, and so is a startup
flag set that differs from the stored one (flags are set while a deployment
is stopped, and the restart just started it). A value equal to what is
already live is not reported: nothing was lost.
"""
supplied = (("--min", request.minimum, compute.get("min")), ("--max", request.maximum, compute.get("max")))
return tuple(flag for flag, value, live in supplied if value is not None and value != live)
dropped = [flag for flag, value, live in supplied if value is not None and value != live]
if _startup_args_changed(request, compute):
dropped.append("--startup-arg")
return tuple(dropped)


def _startup_args_changed(request: UpRequest, compute: JsonObject) -> bool:
"""Whether the caller named startup flags that differ from the live set.

An omitted flag is never a change, and the same list as live is not one
either: re-running `up` with the flags it was created with must be a no-op.
"""
return request.startup_args is not None and list(request.startup_args) != compute.get("startupArgs", [])


def _startup_args_remedy(deployment_id: str, also: str = "") -> str:
"""Startup flags are set only while a deployment is stopped, so the remedy
is always the same three steps; ``also`` rides on the `scale`."""
return (
f"run `comfy deploy stop --deployment {deployment_id}`, then "
f"`comfy deploy scale --deployment {deployment_id} --startup-arg=<flag>{also}`, then "
f"`comfy deploy start --deployment {deployment_id}`"
)


def reconcile_up(builder: BuilderReleaseClient, client: DeployUpClient, request: UpRequest) -> UpResult:
Expand All @@ -152,6 +176,8 @@ def reconcile_up(builder: BuilderReleaseClient, client: DeployUpClient, request:
"min": minimum,
"max": maximum,
}
if request.startup_args is not None:
compute["startupArgs"] = list(request.startup_args)
snapshot = _create_live_deployment(client, request, compute)
return UpResult(snapshot, _release_summary(request.release), compute, supersedes, True, True)

Expand All @@ -166,21 +192,36 @@ def reconcile_up(builder: BuilderReleaseClient, client: DeployUpClient, request:
)
deployment_id = _required_string(existing, "id")
status = _required_string(existing, "status")
dropped = _dropped_bounds(request, compute)
dropped = _dropped_flags(request, compute)
if status in {"stopped", "failed"}:
started = client.start_deployment(deployment_id)
return UpResult(started, _release_summary(request.release), compute, supersedes, False, True, dropped)
if status == "stop_failed":
return UpResult(existing, _release_summary(request.release), compute, supersedes, False, False, dropped)
# Startup flags are set when compute is provisioned and the service refuses
# to change them on a running deployment, the same as gpuClass and region.
# Refused here for the same reason those are: the remedy is stop, then
# `scale --startup-arg`, and a 409 relayed from the service says less.
if _startup_args_changed(request, compute):
raise DeployAPIError(
"deploy_immutable_compute",
"a running deployment cannot change startupArgs in place",
details={"deploymentId": deployment_id, "computeConfig": compute},
hint=_startup_args_remedy(deployment_id),
)
# An omitted bound keeps the live value, exactly as `comfy deploy scale`
# merges: re-running `up` after a release must not silently unscale.
desired = {**compute}
for bound, requested in (("min", request.minimum), ("max", request.maximum)):
if requested is not None:
desired[bound] = requested
if desired != compute:
updated = client.update_deployment(deployment_id, desired)
return UpResult(updated, _release_summary(request.release), desired, supersedes, False, True)
# The stored flags are left out of the request, as `scale` leaves them
# out: the service keeps them when the field is absent, and a copy read
# before this edit could be stale. The response is what is stored now.
body = {key: value for key, value in desired.items() if key != "startupArgs"}
updated = client.update_deployment(deployment_id, body)
return UpResult(updated, _release_summary(request.release), _compute_config(updated), supersedes, False, True)
return UpResult(existing, _release_summary(request.release), compute, supersedes, False, False)


Expand All @@ -196,17 +237,22 @@ def _render_result(renderer, result: UpResult, *, watch: bool) -> None:
)
elif watch and status in {"failed", "stopped"}:
renderer.warn(f"Deployment {deployment_id} reached terminal status {status}.")
if result.dropped_bounds:
joined = " and ".join(result.dropped_bounds)
renderer.warn(
f"{joined} had no effect; deployment {deployment_id} kept its existing worker bounds.",
if result.dropped_flags:
joined = " and ".join(result.dropped_flags)
bounds = " --min <n> --max <n>" if any(flag != "--startup-arg" for flag in result.dropped_flags) else ""
if status == "stop_failed":
# `scale` is only actionable once the deployment settles: the API
# rejects an edit unless it is ready or stopped (`run_scale` re-wraps
# that as `deploy_conflict`), so a `stop_failed` deployment is sent
# to the stop remedy warned about just above instead.
hint=None
if status == "stop_failed"
else f"run `comfy deploy scale --deployment {deployment_id} --min <n> --max <n>` to change them",
hint = None
elif "--startup-arg" in result.dropped_flags:
hint = _startup_args_remedy(deployment_id, bounds)
else:
hint = f"run `comfy deploy scale --deployment {deployment_id}{bounds}` to change them"
renderer.warn(
f"{joined} had no effect; deployment {deployment_id} kept its existing compute settings.",
hint=hint,
)
terminal = status in {"failed", "stopped", "stop_failed"}
renderer.emit(
Expand Down
8 changes: 8 additions & 0 deletions comfy_cli/deploy_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,14 @@ def _validate_compute_config(compute_config: dict) -> None:
# refused against the placeholder max of 1 above — a ceiling nobody set.
if "min" in compute_config and "max" in compute_config and minimum > maximum:
raise bad_request("computeConfig.min must not exceed computeConfig.max")
# Shape only; which flags are allowed is the service's call and its 400
# names the offending token.
startup_args = compute_config.get("startupArgs")
if startup_args is not None and (
not isinstance(startup_args, list)
or any(not isinstance(token, str) or not token.strip() for token in startup_args)
):
raise bad_request("computeConfig.startupArgs must be a list of ComfyUI flag tokens")


class DeployClient:
Expand Down
9 changes: 8 additions & 1 deletion comfy_cli/deploy_api_errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,14 @@ def _compute_or_bad_request(message: str) -> dict[str, str]:


def _immutable_or_conflict(message: str) -> dict[str, str]:
return _IMMUTABLE_COMPUTE if "changing gpuclass or region" in message.lower() else _CONFLICT
# The service names the field it refused in its own 409 text; both are
# "stop the deployment first" refusals rather than a state conflict.
lowered = message.lower()
return (
_IMMUTABLE_COMPUTE
if "changing gpuclass or region" in lowered or "changing startupargs" in lowered
else _CONFLICT
)


def _deleted_or_conflict(message: str) -> dict[str, str]:
Expand Down
13 changes: 11 additions & 2 deletions comfy_cli/error_codes.py
Original file line number Diff line number Diff line change
Expand Up @@ -1282,6 +1282,13 @@ class ErrorCode:
"choose. `details.missing` lists every required option.",
"pass every option named in `details.missing`, then retry",
),
ErrorCode(
"deploy_conflicting_input",
"`comfy deploy scale` was given options that cancel each other: `--startup-arg` sets the ComfyUI startup "
"flags and `--clear-startup-args` removes them, so both at once has no meaning the service could honor. "
"`details.conflicting` names the pair.",
"pass either `--startup-arg` or `--clear-startup-args`, not both, then retry",
),
ErrorCode(
"deploy_bad_request",
"The deploy control plane rejected structurally invalid input. The message names the invalid field or query parameter.",
Expand Down Expand Up @@ -1329,8 +1336,10 @@ class ErrorCode:
),
ErrorCode(
"deploy_immutable_compute",
"A ready deployment cannot change its GPU class or region in place.",
"run `comfy deploy stop`, then `comfy deploy scale --gpu <class> --region <region>`, then `comfy deploy start`",
"A ready deployment cannot change its GPU class, region or ComfyUI startup flags in place. The message "
"names the setting.",
"run `comfy deploy stop`, then `comfy deploy scale` with the setting to change (`--gpu <class> --region "
"<region>` or `--startup-arg=<flag>`), then `comfy deploy start`",
),
ErrorCode(
"deploy_deleted",
Expand Down
3 changes: 2 additions & 1 deletion comfy_cli/schemas/deploy_scale.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
"gpuClass": {"type": "string", "minLength": 1},
"region": {"type": "string", "minLength": 1},
"min": {"type": "integer", "minimum": 0, "maximum": 20},
"max": {"type": "integer", "minimum": 1, "maximum": 20}
"max": {"type": "integer", "minimum": 1, "maximum": 20},
"startupArgs": {"type": "array", "items": {"type": "string", "pattern": "\\S"}}
}
},
"id": {"type": "string", "minLength": 1},
Expand Down
3 changes: 2 additions & 1 deletion comfy_cli/schemas/deploy_status.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,8 @@
"gpuClass": {"type": "string", "minLength": 1},
"region": {"type": "string", "minLength": 1},
"min": {"type": "integer", "minimum": 0},
"max": {"type": "integer", "minimum": 1}
"max": {"type": "integer", "minimum": 1},
"startupArgs": {"type": "array", "items": {"type": "string", "pattern": "\\S"}}
}
},
"stopReason": {"enum": [null, "user", "credits", "policy"]},
Expand Down
Loading
Loading