mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-18 07:37:55 +02:00
Fix customer-registry deployment creation and image handling
Co-authored-by: open-swe[bot] <open-swe@users.noreply.github.com>
This commit is contained in:
co-authored by
open-swe[bot] <open-swe@users.noreply.github.com>
parent
d707ab6650
commit
9779ea5d3a
+117
-141
@@ -639,15 +639,23 @@ def _create_deployment(
|
|||||||
source: str,
|
source: str,
|
||||||
config_rel: str | None = None,
|
config_rel: str | None = None,
|
||||||
secrets: list[dict[str, str]] | None = None,
|
secrets: list[dict[str, str]] | None = None,
|
||||||
|
image_uri: str | None = None,
|
||||||
|
tracked_packages: list[str] | None = None,
|
||||||
) -> tuple[str, int]:
|
) -> tuple[str, int]:
|
||||||
"""Create a deployment and return its ID and next step number."""
|
"""Create a deployment and return its ID and next step number."""
|
||||||
_log_deploy_step(step, f"Creating deployment '{name}'")
|
_log_deploy_step(step, f"Creating deployment '{name}'")
|
||||||
|
extra = (
|
||||||
|
{"image_uri": image_uri, "tracked_packages": tracked_packages}
|
||||||
|
if image_uri
|
||||||
|
else {}
|
||||||
|
)
|
||||||
created = client.create_deployment(
|
created = client.create_deployment(
|
||||||
name=name,
|
name=name,
|
||||||
deployment_type=deployment_type,
|
deployment_type=deployment_type,
|
||||||
source=source,
|
source=source,
|
||||||
config_path=config_rel,
|
config_path=config_rel,
|
||||||
secrets=secrets,
|
secrets=secrets,
|
||||||
|
**extra,
|
||||||
)
|
)
|
||||||
created_id = created.get("id") if isinstance(created, dict) else None
|
created_id = created.get("id") if isinstance(created, dict) else None
|
||||||
if not isinstance(created_id, str) or not created_id:
|
if not isinstance(created_id, str) or not created_id:
|
||||||
@@ -666,7 +674,6 @@ def _smith_dashboard_base_url(host_url: str | None) -> str:
|
|||||||
return "https://smith.langchain.com"
|
return "https://smith.langchain.com"
|
||||||
parsed = urlparse(host_url)
|
parsed = urlparse(host_url)
|
||||||
hostname = parsed.hostname or ""
|
hostname = parsed.hostname or ""
|
||||||
# Self-hosted: host_url is <scheme>://<host>/api-host — return just the root
|
|
||||||
path = parsed.path.rstrip("/")
|
path = parsed.path.rstrip("/")
|
||||||
if path == "/api-host" or path.endswith("/api-host"):
|
if path == "/api-host" or path.endswith("/api-host"):
|
||||||
return f"{parsed.scheme}://{parsed.netloc}"
|
return f"{parsed.scheme}://{parsed.netloc}"
|
||||||
@@ -946,43 +953,29 @@ def _build_image_tagged(
|
|||||||
verbose: bool,
|
verbose: bool,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Build a Docker image to *tag*, using buildx on non-x86_64 hosts to target linux/amd64."""
|
"""Build a Docker image to *tag*, using buildx on non-x86_64 hosts to target linux/amd64."""
|
||||||
if platform.machine() != "x86_64":
|
needs_buildx = platform.machine() != "x86_64"
|
||||||
build_flags: list[str] = ["--platform", "linux/amd64", "--load"]
|
build_flags = ["--platform", "linux/amd64", "--load"] if needs_buildx else []
|
||||||
if not verbose:
|
if needs_buildx and not verbose:
|
||||||
build_flags.append("--progress=quiet")
|
build_flags.append("--progress=quiet")
|
||||||
with Progress(message="Building...", elapsed=not verbose):
|
with Progress(message="Building...", elapsed=not verbose):
|
||||||
build_docker_image(
|
build_docker_image(
|
||||||
runner,
|
runner,
|
||||||
lambda _msg: None,
|
lambda _msg: None,
|
||||||
config,
|
config,
|
||||||
config_json,
|
config_json,
|
||||||
base_image,
|
base_image,
|
||||||
api_version,
|
api_version,
|
||||||
pull,
|
pull,
|
||||||
tag,
|
tag,
|
||||||
docker_build_args,
|
docker_build_args,
|
||||||
install_command,
|
install_command,
|
||||||
build_command,
|
build_command,
|
||||||
docker_command=("docker", "buildx", "build"),
|
docker_command=("docker", "buildx", "build")
|
||||||
extra_flags=build_flags,
|
if needs_buildx
|
||||||
verbose=verbose,
|
else ("docker", "build"),
|
||||||
)
|
extra_flags=build_flags,
|
||||||
else:
|
verbose=verbose,
|
||||||
with Progress(message="Building...", elapsed=not verbose):
|
)
|
||||||
build_docker_image(
|
|
||||||
runner,
|
|
||||||
lambda _msg: None,
|
|
||||||
config,
|
|
||||||
config_json,
|
|
||||||
base_image,
|
|
||||||
api_version,
|
|
||||||
pull,
|
|
||||||
tag,
|
|
||||||
docker_build_args,
|
|
||||||
install_command,
|
|
||||||
build_command,
|
|
||||||
verbose=verbose,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_local_build(
|
def _run_local_build(
|
||||||
@@ -1152,14 +1145,13 @@ def _run_local_build(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def _run_external_deploy(
|
def _push_external_image(
|
||||||
*,
|
*,
|
||||||
client: HostBackendClient,
|
|
||||||
deployment_id: str,
|
|
||||||
step: int,
|
step: int,
|
||||||
config: pathlib.Path,
|
config: pathlib.Path,
|
||||||
config_json: dict,
|
config_json: dict,
|
||||||
image_uri: str,
|
push_to: str,
|
||||||
|
prebuilt_image: str | None,
|
||||||
verbose: bool,
|
verbose: bool,
|
||||||
pull: bool,
|
pull: bool,
|
||||||
api_version: str | None,
|
api_version: str | None,
|
||||||
@@ -1167,55 +1159,40 @@ def _run_external_deploy(
|
|||||||
install_command: str | None,
|
install_command: str | None,
|
||||||
build_command: str | None,
|
build_command: str | None,
|
||||||
docker_build_args: Sequence[str],
|
docker_build_args: Sequence[str],
|
||||||
secrets: list[dict[str, str]],
|
) -> str:
|
||||||
tracked_packages: list[str] | None,
|
"""Build or retag an image and push using existing Docker credentials."""
|
||||||
) -> "BuildResult":
|
|
||||||
"""Build image, push using existing Docker credentials, and update the deployment."""
|
|
||||||
with Runner() as runner:
|
with Runner() as runner:
|
||||||
_log_deploy_step(step, f"Building image {image_uri}")
|
if prebuilt_image:
|
||||||
_build_image_tagged(
|
_log_deploy_step(step, f"Validating image {prebuilt_image}")
|
||||||
runner,
|
_validate_prebuilt_image(runner, prebuilt_image, verbose=verbose)
|
||||||
config,
|
runner.run(
|
||||||
config_json,
|
subp_exec("docker", "tag", prebuilt_image, push_to, verbose=verbose)
|
||||||
base_image,
|
)
|
||||||
api_version,
|
else:
|
||||||
pull,
|
_log_deploy_step(step, f"Building image {push_to}")
|
||||||
image_uri,
|
_build_image_tagged(
|
||||||
docker_build_args,
|
runner,
|
||||||
install_command,
|
config,
|
||||||
build_command,
|
config_json,
|
||||||
verbose=verbose,
|
base_image,
|
||||||
)
|
api_version,
|
||||||
step += 1
|
pull,
|
||||||
|
push_to,
|
||||||
_log_deploy_step(step, f"Pushing image {image_uri}")
|
docker_build_args,
|
||||||
|
install_command,
|
||||||
|
build_command,
|
||||||
|
verbose=verbose,
|
||||||
|
)
|
||||||
|
_log_deploy_step(step + 1, f"Pushing image {push_to}")
|
||||||
with Progress(message="Pushing...", elapsed=not verbose):
|
with Progress(message="Pushing...", elapsed=not verbose):
|
||||||
runner.run(subp_exec("docker", "push", image_uri, verbose=verbose))
|
runner.run(subp_exec("docker", "push", push_to, verbose=verbose))
|
||||||
step += 1
|
return _resolve_pushed_image_digest(
|
||||||
|
|
||||||
resolved_image = _resolve_pushed_image_digest(
|
|
||||||
runner,
|
runner,
|
||||||
remote_image=image_uri,
|
remote_image=push_to,
|
||||||
docker_config_dir=None,
|
docker_config_dir=None,
|
||||||
verbose=verbose,
|
verbose=verbose,
|
||||||
)
|
)
|
||||||
|
|
||||||
_log_deploy_step(step, f"Updating deployment {deployment_id}")
|
|
||||||
updated = client.update_deployment_external(
|
|
||||||
deployment_id,
|
|
||||||
resolved_image,
|
|
||||||
secrets=secrets,
|
|
||||||
tracked_packages=tracked_packages,
|
|
||||||
)
|
|
||||||
|
|
||||||
return BuildResult(
|
|
||||||
updated=updated if isinstance(updated, dict) else {},
|
|
||||||
progress_message="Deploying...",
|
|
||||||
timeout_seconds=300,
|
|
||||||
poll_interval_seconds=1,
|
|
||||||
no_result_message="Deployment updated",
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
def _run_remote_build(
|
def _run_remote_build(
|
||||||
*,
|
*,
|
||||||
@@ -1352,30 +1329,20 @@ def _create_host_backend_client(
|
|||||||
tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get(
|
tenant_id = env_vars.get("LANGSMITH_TENANT_ID") or os.environ.get(
|
||||||
"LANGSMITH_TENANT_ID"
|
"LANGSMITH_TENANT_ID"
|
||||||
)
|
)
|
||||||
# If no explicit host URL was provided, check LANGSMITH_ENDPOINT as a
|
if not host_url:
|
||||||
# fallback so self-hosted customers don't need to know about LANGGRAPH_HOST_URL.
|
from urllib.parse import urlparse
|
||||||
# Self-hosted control plane always lives at <langsmith_endpoint>/api-host.
|
|
||||||
_cloud_default = "https://api.host.langchain.com"
|
endpoint = env_vars.get("LANGSMITH_ENDPOINT") or os.environ.get(
|
||||||
_cloud_endpoints = {
|
|
||||||
"https://api.smith.langchain.com",
|
|
||||||
"https://api.langchain.com",
|
|
||||||
}
|
|
||||||
resolved_host = host_url
|
|
||||||
if not resolved_host or resolved_host == _cloud_default:
|
|
||||||
langsmith_endpoint = env_vars.get("LANGSMITH_ENDPOINT") or os.environ.get(
|
|
||||||
"LANGSMITH_ENDPOINT"
|
"LANGSMITH_ENDPOINT"
|
||||||
)
|
)
|
||||||
if (
|
parsed = urlparse(endpoint or "https://api.smith.langchain.com")
|
||||||
langsmith_endpoint
|
if parsed.hostname in ("api.smith.langchain.com", "api.langchain.com"):
|
||||||
and langsmith_endpoint.rstrip("/") not in _cloud_endpoints
|
host_url = "https://api.host.langchain.com"
|
||||||
):
|
elif parsed.hostname == "eu.api.smith.langchain.com":
|
||||||
from urllib.parse import urlparse as _urlparse
|
host_url = "https://eu.api.host.langchain.com"
|
||||||
|
|
||||||
_p = _urlparse(langsmith_endpoint)
|
|
||||||
resolved_host = f"{_p.scheme}://{_p.netloc}/api-host"
|
|
||||||
else:
|
else:
|
||||||
resolved_host = _cloud_default
|
host_url = f"{parsed.scheme}://{parsed.netloc}/api-host"
|
||||||
return HostBackendClient(resolved_host, resolved_api_key, tenant_id=tenant_id)
|
return HostBackendClient(host_url, resolved_api_key, tenant_id=tenant_id)
|
||||||
|
|
||||||
|
|
||||||
def _call_host_backend_with_optional_tenant(
|
def _call_host_backend_with_optional_tenant(
|
||||||
@@ -1454,7 +1421,7 @@ OPT_HOST_DEPLOYMENT_NAME = click.option(
|
|||||||
OPT_HOST_URL = click.option(
|
OPT_HOST_URL = click.option(
|
||||||
"--host-url",
|
"--host-url",
|
||||||
envvar="LANGGRAPH_HOST_URL",
|
envvar="LANGGRAPH_HOST_URL",
|
||||||
default="https://api.host.langchain.com",
|
default=None,
|
||||||
hidden=True,
|
hidden=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1594,13 +1561,11 @@ def _deploy_base_options(
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
click.option(
|
click.option(
|
||||||
"--image-uri",
|
"--push-to",
|
||||||
help=(
|
help=(
|
||||||
"Image URI to build, push, and deploy "
|
"Push to this customer-managed registry URI for self-hosted/hybrid "
|
||||||
"(e.g. 123456789.dkr.ecr.us-east-1.amazonaws.com/repo:tag). "
|
"deployments using existing Docker credentials. Builds the project "
|
||||||
"Builds the project, pushes using existing Docker credentials, "
|
"or retags the local image supplied with --image."
|
||||||
"and triggers a deployment revision. "
|
|
||||||
"Required for self-hosted deployments."
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
click.option(
|
click.option(
|
||||||
@@ -1704,7 +1669,7 @@ def _deploy_cmd(
|
|||||||
name: str | None,
|
name: str | None,
|
||||||
image_name: str | None,
|
image_name: str | None,
|
||||||
image: str | None,
|
image: str | None,
|
||||||
image_uri: str | None,
|
push_to: str | None,
|
||||||
tag: str,
|
tag: str,
|
||||||
base_image: str | None,
|
base_image: str | None,
|
||||||
install_command: str | None,
|
install_command: str | None,
|
||||||
@@ -1751,12 +1716,10 @@ def _deploy_cmd(
|
|||||||
|
|
||||||
secrets = _secrets_from_env(_env_without_deployment_name(env_vars))
|
secrets = _secrets_from_env(_env_without_deployment_name(env_vars))
|
||||||
|
|
||||||
if image_uri and remote_build_flag is True:
|
if push_to and remote_build_flag is True:
|
||||||
raise click.UsageError("--image-uri cannot be combined with --remote.")
|
raise click.UsageError("--push-to cannot be combined with --remote.")
|
||||||
if image_uri and image:
|
|
||||||
raise click.UsageError("--image-uri cannot be combined with --image.")
|
|
||||||
|
|
||||||
use_external_docker = image_uri is not None
|
use_external_docker = push_to is not None
|
||||||
|
|
||||||
if use_external_docker:
|
if use_external_docker:
|
||||||
use_remote_build = False
|
use_remote_build = False
|
||||||
@@ -1782,45 +1745,35 @@ def _deploy_cmd(
|
|||||||
name,
|
name,
|
||||||
not_found_message=(
|
not_found_message=(
|
||||||
"No deployment found. Will create."
|
"No deployment found. Will create."
|
||||||
if (use_remote_build or use_external_docker)
|
if use_remote_build
|
||||||
else "No deployment found. Will create after build."
|
else "No deployment found. Will create after build."
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
if needs_creation:
|
if needs_creation and not use_external_docker:
|
||||||
if use_external_docker:
|
|
||||||
source = "external_docker"
|
|
||||||
elif use_remote_build:
|
|
||||||
source = "internal_source"
|
|
||||||
else:
|
|
||||||
source = "internal_docker"
|
|
||||||
deployment_id, step = _create_deployment(
|
deployment_id, step = _create_deployment(
|
||||||
client,
|
client,
|
||||||
step,
|
step,
|
||||||
name=name,
|
name=name,
|
||||||
deployment_type=deployment_type,
|
deployment_type=deployment_type,
|
||||||
source=source,
|
source="internal_source" if use_remote_build else "internal_docker",
|
||||||
secrets=secrets,
|
secrets=secrets,
|
||||||
)
|
)
|
||||||
|
|
||||||
if not deployment_id:
|
if not deployment_id and not use_external_docker:
|
||||||
raise click.ClickException("Failed to determine deployment ID")
|
raise click.ClickException("Failed to determine deployment ID")
|
||||||
|
|
||||||
# Validate that an existing deployment is compatible with --image-uri.
|
|
||||||
# update_deployment_external sends an external_docker revision; applying
|
|
||||||
# it to a deployment created with a different source mode may be rejected
|
|
||||||
# by the backend or silently produce an inconsistent revision.
|
|
||||||
if use_external_docker and not needs_creation:
|
if use_external_docker and not needs_creation:
|
||||||
existing = _call_host_backend_with_optional_tenant(
|
existing = _call_host_backend_with_optional_tenant(
|
||||||
client, lambda c: c.get_deployment(deployment_id)
|
client, lambda c: c.get_deployment(deployment_id)
|
||||||
)
|
)
|
||||||
existing_source = existing.get("source") if isinstance(existing, dict) else None
|
existing_source = existing.get("source") if isinstance(existing, dict) else None
|
||||||
if existing_source and existing_source != "external_docker":
|
if existing_source != "external_docker":
|
||||||
raise click.UsageError(
|
raise click.UsageError(
|
||||||
f"Deployment {deployment_id} uses a different build mode and "
|
f"Deployment {deployment_id} uses a different build mode and "
|
||||||
f"cannot be updated with --image-uri. To use --image-uri, omit "
|
f"cannot be updated with --push-to. To use --push-to, omit "
|
||||||
f"--deployment-id to create a new deployment, or remove "
|
f"--deployment-id to create a new deployment, or remove "
|
||||||
f"--image-uri to continue using the current build mode."
|
f"--push-to to continue using the current build mode."
|
||||||
)
|
)
|
||||||
|
|
||||||
# Scan local sources for tracked packages so the new revision carries
|
# Scan local sources for tracked packages so the new revision carries
|
||||||
@@ -1834,13 +1787,12 @@ def _deploy_cmd(
|
|||||||
|
|
||||||
# -- 3. Build (divergent path) --
|
# -- 3. Build (divergent path) --
|
||||||
if use_external_docker:
|
if use_external_docker:
|
||||||
build_result = _run_external_deploy(
|
resolved_image = _push_external_image(
|
||||||
client=client,
|
|
||||||
deployment_id=deployment_id,
|
|
||||||
step=step,
|
step=step,
|
||||||
config=config,
|
config=config,
|
||||||
config_json=config_json,
|
config_json=config_json,
|
||||||
image_uri=image_uri,
|
push_to=push_to,
|
||||||
|
prebuilt_image=image,
|
||||||
verbose=verbose,
|
verbose=verbose,
|
||||||
pull=pull,
|
pull=pull,
|
||||||
api_version=api_version,
|
api_version=api_version,
|
||||||
@@ -1848,8 +1800,32 @@ def _deploy_cmd(
|
|||||||
install_command=install_command,
|
install_command=install_command,
|
||||||
build_command=build_command,
|
build_command=build_command,
|
||||||
docker_build_args=docker_build_args,
|
docker_build_args=docker_build_args,
|
||||||
secrets=secrets,
|
)
|
||||||
tracked_packages=tracked_packages,
|
if needs_creation:
|
||||||
|
deployment_id, step = _create_deployment(
|
||||||
|
client,
|
||||||
|
step + 2,
|
||||||
|
name=name,
|
||||||
|
deployment_type=deployment_type,
|
||||||
|
source="external_docker",
|
||||||
|
secrets=secrets,
|
||||||
|
image_uri=resolved_image,
|
||||||
|
tracked_packages=tracked_packages,
|
||||||
|
)
|
||||||
|
updated = client.get_deployment(deployment_id)
|
||||||
|
else:
|
||||||
|
updated = client.update_deployment_external(
|
||||||
|
deployment_id,
|
||||||
|
resolved_image,
|
||||||
|
secrets=secrets,
|
||||||
|
tracked_packages=tracked_packages,
|
||||||
|
)
|
||||||
|
build_result = BuildResult(
|
||||||
|
updated=updated,
|
||||||
|
progress_message="Deploying...",
|
||||||
|
timeout_seconds=300,
|
||||||
|
poll_interval_seconds=1,
|
||||||
|
no_result_message="Deployment updated",
|
||||||
)
|
)
|
||||||
elif use_remote_build:
|
elif use_remote_build:
|
||||||
build_result = _run_remote_build(
|
build_result = _run_remote_build(
|
||||||
|
|||||||
@@ -81,6 +81,8 @@ class HostBackendClient:
|
|||||||
source: str,
|
source: str,
|
||||||
config_path: str | None = None,
|
config_path: str | None = None,
|
||||||
secrets: list[dict[str, str]] | None = None,
|
secrets: list[dict[str, str]] | None = None,
|
||||||
|
image_uri: str | None = None,
|
||||||
|
tracked_packages: list[str] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Create a deployment."""
|
"""Create a deployment."""
|
||||||
payload: dict[str, Any] = {
|
payload: dict[str, Any] = {
|
||||||
@@ -89,6 +91,11 @@ class HostBackendClient:
|
|||||||
"source_config": {"deployment_type": deployment_type},
|
"source_config": {"deployment_type": deployment_type},
|
||||||
"source_revision_config": {},
|
"source_revision_config": {},
|
||||||
}
|
}
|
||||||
|
if source == "external_docker":
|
||||||
|
payload["source_config"] = {}
|
||||||
|
payload["source_revision_config"] = {"image_uri": image_uri}
|
||||||
|
if tracked_packages:
|
||||||
|
payload["tracked_packages"] = tracked_packages
|
||||||
if source == "internal_source" and config_path:
|
if source == "internal_source" and config_path:
|
||||||
payload["source_revision_config"]["langgraph_config_path"] = config_path
|
payload["source_revision_config"]["langgraph_config_path"] = config_path
|
||||||
if secrets is not None:
|
if secrets is not None:
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import tempfile
|
|||||||
import textwrap
|
import textwrap
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from unittest.mock import MagicMock
|
||||||
|
|
||||||
import click
|
import click
|
||||||
|
import pytest
|
||||||
from click.testing import CliRunner
|
from click.testing import CliRunner
|
||||||
|
|
||||||
import langgraph_cli.deploy as deploy_module
|
import langgraph_cli.deploy as deploy_module
|
||||||
@@ -1356,47 +1358,89 @@ def test_prepare_args_and_stdin_distributed_mode() -> None:
|
|||||||
assert "executor_entrypoint.sh" in actual_stdin
|
assert "executor_entrypoint.sh" in actual_stdin
|
||||||
|
|
||||||
|
|
||||||
def test_deploy_image_uri_rejects_incompatible_source(monkeypatch, tmp_path) -> None:
|
@pytest.mark.parametrize(
|
||||||
"""--image-uri raises UsageError when applied to a non-external_docker deployment."""
|
"source,image",
|
||||||
# --no-input sets the module-level _no_input global; ensure it's restored.
|
[
|
||||||
|
(None, None),
|
||||||
|
(None, "local:latest"),
|
||||||
|
("external_docker", None),
|
||||||
|
("external_docker", "local:latest"),
|
||||||
|
("internal_docker", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_deploy_push_to(monkeypatch, tmp_path, source, image):
|
||||||
monkeypatch.setattr(deploy_module, "_no_input", False)
|
monkeypatch.setattr(deploy_module, "_no_input", False)
|
||||||
|
monkeypatch.setattr(deploy_module, "_emitter", None)
|
||||||
config = tmp_path / "langgraph.json"
|
config = tmp_path / "langgraph.json"
|
||||||
config.write_text('{"graphs": {"agent": "agent.py:graph"}, "dependencies": ["."]}')
|
config.write_text('{"graphs": {"agent": "agent.py:graph"}, "dependencies": ["."]}')
|
||||||
|
events = []
|
||||||
class FakeClient:
|
client = MagicMock(base_url="https://smith.example.com/api-host")
|
||||||
def __init__(self, host_url: str, api_key: str, tenant_id: str | None = None):
|
client.get_deployment.return_value = {"id": "dep-123", "source": source}
|
||||||
self.base_url = host_url
|
client.list_deployments.return_value = {"deployments": []}
|
||||||
|
client.create_deployment.side_effect = lambda **kw: (
|
||||||
def get_deployment(self, deployment_id: str):
|
events.append(("create", kw)) or {"id": "dep-123"}
|
||||||
return {
|
|
||||||
"id": deployment_id,
|
|
||||||
"name": "test-deploy",
|
|
||||||
"source": "internal_docker",
|
|
||||||
"tenant_id": "tenant-1",
|
|
||||||
}
|
|
||||||
|
|
||||||
monkeypatch.setattr(deploy_module, "HostBackendClient", FakeClient)
|
|
||||||
|
|
||||||
runner = CliRunner()
|
|
||||||
result = runner.invoke(
|
|
||||||
cli,
|
|
||||||
[
|
|
||||||
"deploy",
|
|
||||||
"--api-key",
|
|
||||||
"test-key",
|
|
||||||
"--host-url",
|
|
||||||
"https://api.example.com",
|
|
||||||
"--deployment-id",
|
|
||||||
"dep-123",
|
|
||||||
"--image-uri",
|
|
||||||
"registry.example.com/app:latest",
|
|
||||||
"--config",
|
|
||||||
str(config),
|
|
||||||
"--no-input",
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
client.update_deployment_external.side_effect = lambda *a, **kw: (
|
||||||
assert result.exit_code != 0
|
events.append(("update", a)) or {}
|
||||||
assert "different build mode" in result.output
|
)
|
||||||
assert "cannot be updated with --image-uri" in result.output
|
monkeypatch.setattr(deploy_module, "HostBackendClient", lambda *a, **kw: client)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
deploy_module,
|
||||||
|
"_build_image_tagged",
|
||||||
|
lambda *a, **kw: events.append(("build", a[6])),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
deploy_module,
|
||||||
|
"_validate_prebuilt_image",
|
||||||
|
lambda *a, **kw: events.append(("validate", a[1])),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(deploy_module, "subp_exec", lambda *a, **kw: a)
|
||||||
|
runner = MagicMock()
|
||||||
|
runner.__enter__.return_value = runner
|
||||||
|
runner.run.side_effect = lambda args: events.append(args)
|
||||||
|
monkeypatch.setattr(deploy_module, "Runner", lambda: runner)
|
||||||
|
digest = "registry.example.com/app@sha256:abc123"
|
||||||
|
monkeypatch.setattr(
|
||||||
|
deploy_module,
|
||||||
|
"_resolve_pushed_image_digest",
|
||||||
|
lambda *a, **kw: events.append(("digest",)) or digest,
|
||||||
|
)
|
||||||
|
args = [
|
||||||
|
"deploy",
|
||||||
|
"--api-key",
|
||||||
|
"key",
|
||||||
|
"--push-to",
|
||||||
|
"registry.example.com/app:latest",
|
||||||
|
"--config",
|
||||||
|
str(config),
|
||||||
|
"--no-input",
|
||||||
|
"--no-wait",
|
||||||
|
]
|
||||||
|
args += ["--deployment-id", "dep-123"] if source else ["--name", "app"]
|
||||||
|
if image:
|
||||||
|
args += ["--image", image]
|
||||||
|
result = CliRunner().invoke(cli, args)
|
||||||
|
if source == "internal_docker":
|
||||||
|
assert result.exit_code != 0
|
||||||
|
assert "cannot be updated with --push-to" in result.output
|
||||||
|
assert events == []
|
||||||
|
return
|
||||||
|
assert result.exit_code == 0, result.output
|
||||||
|
destination = "registry.example.com/app:latest"
|
||||||
|
expected = (
|
||||||
|
[("validate", image), ("docker", "tag", image, destination)]
|
||||||
|
if image
|
||||||
|
else [("build", destination)]
|
||||||
|
)
|
||||||
|
assert events[: len(expected) + 2] == expected + [
|
||||||
|
("docker", "push", destination),
|
||||||
|
("digest",),
|
||||||
|
]
|
||||||
|
if source:
|
||||||
|
assert events[-1] == ("update", ("dep-123", digest))
|
||||||
|
client.create_deployment.assert_not_called()
|
||||||
|
else:
|
||||||
|
assert events[-1][0] == "create"
|
||||||
|
assert events[-1][1]["image_uri"] == digest
|
||||||
|
assert events[-1][1]["source"] == "external_docker"
|
||||||
|
client.update_deployment_external.assert_not_called()
|
||||||
|
|||||||
@@ -543,53 +543,47 @@ class TestCreateHostBackendClientNoInput:
|
|||||||
assert client is not None
|
assert client is not None
|
||||||
|
|
||||||
|
|
||||||
class TestCreateHostBackendClientEndpointFallback:
|
@pytest.mark.parametrize(
|
||||||
def test_langsmith_endpoint_env_var_used_as_fallback(self, monkeypatch):
|
"endpoint,host,expected",
|
||||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
[
|
||||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1")
|
(None, None, "https://api.host.langchain.com"),
|
||||||
monkeypatch.delenv("LANGGRAPH_HOST_URL", raising=False)
|
("https://api.smith.langchain.com/", None, "https://api.host.langchain.com"),
|
||||||
client = _create_host_backend_client(host_url=None, api_key=None, env_vars={})
|
("https://api.langchain.com", None, "https://api.host.langchain.com"),
|
||||||
assert client.base_url == "https://smith.example.com/api-host"
|
(
|
||||||
|
"https://eu.api.smith.langchain.com",
|
||||||
def test_langsmith_endpoint_from_env_vars_dict(self, monkeypatch):
|
None,
|
||||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
"https://eu.api.host.langchain.com",
|
||||||
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
|
),
|
||||||
client = _create_host_backend_client(
|
(
|
||||||
host_url=None,
|
"https://smith.example.com/api/v1",
|
||||||
api_key=None,
|
None,
|
||||||
env_vars={"LANGSMITH_ENDPOINT": "https://smith.example.com/api/v1"},
|
"https://smith.example.com/api-host",
|
||||||
)
|
),
|
||||||
assert client.base_url == "https://smith.example.com/api-host"
|
(
|
||||||
|
"https://smith.example.com",
|
||||||
def test_cloud_langsmith_endpoint_not_used_as_self_hosted(self, monkeypatch):
|
"https://api.host.langchain.com",
|
||||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
"https://api.host.langchain.com",
|
||||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://api.smith.langchain.com")
|
),
|
||||||
client = _create_host_backend_client(host_url=None, api_key=None, env_vars={})
|
(
|
||||||
assert client.base_url == "https://api.host.langchain.com"
|
"https://smith.example.com",
|
||||||
|
"https://custom.host.com",
|
||||||
def test_langchain_api_endpoint_not_used_as_self_hosted(self, monkeypatch):
|
"https://custom.host.com",
|
||||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
),
|
||||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://api.langchain.com")
|
],
|
||||||
client = _create_host_backend_client(host_url=None, api_key=None, env_vars={})
|
)
|
||||||
assert client.base_url == "https://api.host.langchain.com"
|
@pytest.mark.parametrize("from_env", [True, False])
|
||||||
|
def test_endpoint_fallback(monkeypatch, endpoint, host, expected, from_env):
|
||||||
def test_explicit_host_url_takes_precedence_over_langsmith_endpoint(
|
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
|
||||||
self, monkeypatch
|
env_vars = {}
|
||||||
):
|
if endpoint:
|
||||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
if from_env:
|
||||||
monkeypatch.setenv("LANGSMITH_ENDPOINT", "https://smith.example.com/api/v1")
|
monkeypatch.setenv("LANGSMITH_ENDPOINT", endpoint)
|
||||||
client = _create_host_backend_client(
|
else:
|
||||||
host_url="https://custom.host.com",
|
env_vars["LANGSMITH_ENDPOINT"] = endpoint
|
||||||
api_key=None,
|
client = _create_host_backend_client(
|
||||||
env_vars={},
|
host_url=host, api_key="key", env_vars=env_vars
|
||||||
)
|
)
|
||||||
assert client.base_url == "https://custom.host.com"
|
assert client.base_url == expected
|
||||||
|
|
||||||
def test_no_endpoint_falls_back_to_cloud_default(self, monkeypatch):
|
|
||||||
monkeypatch.setenv("LANGSMITH_API_KEY", "lsv2_test")
|
|
||||||
monkeypatch.delenv("LANGSMITH_ENDPOINT", raising=False)
|
|
||||||
client = _create_host_backend_client(host_url=None, api_key=None, env_vars={})
|
|
||||||
assert client.base_url == "https://api.host.langchain.com"
|
|
||||||
|
|
||||||
|
|
||||||
class TestSmithDashboardBaseUrl:
|
class TestSmithDashboardBaseUrl:
|
||||||
@@ -647,23 +641,16 @@ class TestSmithDashboardBaseUrl:
|
|||||||
== "https://smith.langchain.com"
|
== "https://smith.langchain.com"
|
||||||
)
|
)
|
||||||
|
|
||||||
def test_self_hosted_api_host_suffix(self):
|
@pytest.mark.parametrize(
|
||||||
assert (
|
"url,expected",
|
||||||
_smith_dashboard_base_url("https://smith.example.com/api-host")
|
[
|
||||||
== "https://smith.example.com"
|
("https://smith.example.com/api-host", "https://smith.example.com"),
|
||||||
)
|
("https://smith.example.com/api-host/", "https://smith.example.com"),
|
||||||
|
("http://localhost:8080/api-host", "http://localhost:8080"),
|
||||||
def test_self_hosted_api_host_trailing_slash(self):
|
],
|
||||||
assert (
|
)
|
||||||
_smith_dashboard_base_url("https://smith.example.com/api-host/")
|
def test_self_hosted(self, url, expected):
|
||||||
== "https://smith.example.com"
|
assert _smith_dashboard_base_url(url) == expected
|
||||||
)
|
|
||||||
|
|
||||||
def test_self_hosted_localhost_api_host(self):
|
|
||||||
assert (
|
|
||||||
_smith_dashboard_base_url("http://localhost:8080/api-host")
|
|
||||||
== "http://localhost:8080"
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class TestResolvePushedImageDigest:
|
class TestResolvePushedImageDigest:
|
||||||
|
|||||||
@@ -178,45 +178,32 @@ def test_update_deployment_no_secrets(client):
|
|||||||
assert result == {"ok": True}
|
assert result == {"ok": True}
|
||||||
|
|
||||||
|
|
||||||
def test_update_deployment_external():
|
@pytest.mark.parametrize("create", [True, False])
|
||||||
|
def test_external_deployment_payload(create):
|
||||||
captured: dict = {}
|
captured: dict = {}
|
||||||
c = _capturing_client(captured)
|
c = _capturing_client(captured)
|
||||||
result = c.update_deployment_external(
|
image = "registry.example.com/app@sha256:abc123"
|
||||||
"dep-123", "registry.example.com/app@sha256:abc123"
|
kwargs = {
|
||||||
)
|
"secrets": [{"name": "KEY", "value": "value"}],
|
||||||
assert result == {"ok": True}
|
"tracked_packages": ["google-adk:1.0.0"],
|
||||||
|
}
|
||||||
|
if create:
|
||||||
|
c.create_deployment("app", "dev", "external_docker", image_uri=image, **kwargs)
|
||||||
|
else:
|
||||||
|
c.update_deployment_external("dep-123", image, **kwargs)
|
||||||
body = json.loads(captured["body"])
|
body = json.loads(captured["body"])
|
||||||
assert "revision_source" not in body
|
assert body == {
|
||||||
assert body["source_revision_config"]["image_uri"] == (
|
**(
|
||||||
"registry.example.com/app@sha256:abc123"
|
{"name": "app", "source": "external_docker", "source_config": {}}
|
||||||
)
|
if create
|
||||||
|
else {}
|
||||||
|
),
|
||||||
def test_update_deployment_external_forwards_tracked_packages():
|
"source_revision_config": {"image_uri": image},
|
||||||
captured: dict = {}
|
**kwargs,
|
||||||
c = _capturing_client(captured)
|
}
|
||||||
c.update_deployment_external(
|
|
||||||
"dep-123",
|
|
||||||
"registry.example.com/app:latest",
|
|
||||||
tracked_packages=["google-adk:1.0.0"],
|
|
||||||
)
|
|
||||||
body = json.loads(captured["body"])
|
|
||||||
assert body["tracked_packages"] == ["google-adk:1.0.0"]
|
|
||||||
assert "revision_source" not in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_update_deployment_external_omits_tracked_packages_when_absent():
|
|
||||||
captured: dict = {}
|
|
||||||
c = _capturing_client(captured)
|
|
||||||
c.update_deployment_external("dep-123", "registry.example.com/app:latest")
|
|
||||||
body = json.loads(captured["body"])
|
|
||||||
assert "tracked_packages" not in body
|
|
||||||
assert "revision_source" not in body
|
|
||||||
|
|
||||||
|
|
||||||
def test_request_builds_full_url_with_path_prefix():
|
def test_request_builds_full_url_with_path_prefix():
|
||||||
"""base_url with a path prefix (/api-host) must not be dropped when paths start with /."""
|
|
||||||
|
|
||||||
def handler(req: httpx.Request) -> httpx.Response:
|
def handler(req: httpx.Request) -> httpx.Response:
|
||||||
assert str(req.url) == "https://smith.example.com/api-host/v2/deployments"
|
assert str(req.url) == "https://smith.example.com/api-host/v2/deployments"
|
||||||
return httpx.Response(200, json={"ok": True})
|
return httpx.Response(200, json={"ok": True})
|
||||||
|
|||||||
Reference in New Issue
Block a user