mirror of
https://github.com/langchain-ai/langgraph.git
synced 2026-09-23 18:15:08 +02:00
refactor(cli): let the client own its endpoints and split resolving from announcing
This commit is contained in:
@@ -1534,11 +1534,10 @@ class RemoteBuildSource:
|
||||
class CustomerRegistrySource:
|
||||
reference: ImageReference
|
||||
prebuilt_image: str | None
|
||||
placement: RequestedPlacement
|
||||
requested_placement: RequestedPlacement
|
||||
|
||||
def run(self, ctx: DeployContext) -> DeployOutcome:
|
||||
if isinstance(ctx.selector, ById):
|
||||
self.placement.ensure_not_requested(ctx.selector.deployment_id)
|
||||
existing, step = _fetch_deployment(ctx.client, 1, ctx.selector)
|
||||
return self._update(ctx, existing, step)
|
||||
found, step = _find_deployment(
|
||||
@@ -1555,7 +1554,7 @@ class CustomerRegistrySource:
|
||||
self, ctx: DeployContext, existing: ExistingDeployment, step: int
|
||||
) -> DeployOutcome:
|
||||
_ensure_customer_registry_source(existing)
|
||||
self.placement.ensure_not_requested(existing.id)
|
||||
self.requested_placement.ensure_not_requested(existing.id)
|
||||
image_uri, step = self._publish(ctx, step)
|
||||
_log_deploy_step(step, f"Updating deployment {existing.id}")
|
||||
updated = ctx.client.update_deployment(
|
||||
@@ -1569,17 +1568,22 @@ class CustomerRegistrySource:
|
||||
existing.id, _image_revision_result(updated, "Deployment updated")
|
||||
)
|
||||
|
||||
def _placement(self, ctx: DeployContext) -> Placement:
|
||||
places_on_a_listener = ctx.endpoints.is_cloud or self.placement.requested
|
||||
if not places_on_a_listener:
|
||||
def _resolve_placement(self, ctx: DeployContext) -> Placement:
|
||||
if not (ctx.endpoints.is_cloud or self.requested_placement.requested):
|
||||
return Unplaced()
|
||||
placement = self.placement.resolve(_available_listeners(ctx.client))
|
||||
if placement.summary:
|
||||
_get_emitter().info(placement.summary)
|
||||
return placement
|
||||
return self.requested_placement.resolve(_available_listeners(ctx.client))
|
||||
|
||||
def _announce(self, placement: Placement) -> None:
|
||||
if isinstance(placement, OnListener):
|
||||
_get_emitter().info(
|
||||
placement.summary,
|
||||
listener_id=placement.listener_id,
|
||||
k8s_namespace=placement.k8s_namespace,
|
||||
)
|
||||
|
||||
def _create(self, ctx: DeployContext, name: str, step: int) -> DeployOutcome:
|
||||
placement = self._placement(ctx)
|
||||
placement = self._resolve_placement(ctx)
|
||||
self._announce(placement)
|
||||
image_uri, step = self._publish(ctx, step)
|
||||
try:
|
||||
created, _ = _create_deployment(
|
||||
@@ -1661,12 +1665,19 @@ def _select_source(
|
||||
tag: str | None,
|
||||
remote_build_flag: bool | None,
|
||||
placement: RequestedPlacement,
|
||||
selector: DeploymentSelector,
|
||||
) -> DeploymentSource:
|
||||
if push_to is None and placement.requested:
|
||||
raise click.UsageError(
|
||||
"--listener-id and --k8s-namespace only apply when creating a "
|
||||
"deployment with --push-to."
|
||||
)
|
||||
if placement.requested and isinstance(selector, ById):
|
||||
raise click.UsageError(
|
||||
"Listener and namespace are fixed when a deployment is created, so "
|
||||
"they cannot be set for an existing --deployment-id. Drop them, or "
|
||||
"create a new deployment with --name."
|
||||
)
|
||||
if push_to is not None:
|
||||
if remote_build_flag is True:
|
||||
raise click.UsageError("--push-to cannot be combined with --remote.")
|
||||
@@ -1674,7 +1685,9 @@ def _select_source(
|
||||
if image is None:
|
||||
_require_local_docker()
|
||||
return CustomerRegistrySource(
|
||||
reference=reference, prebuilt_image=image, placement=placement
|
||||
reference=reference,
|
||||
prebuilt_image=image,
|
||||
requested_placement=placement,
|
||||
)
|
||||
if image and remote_build_flag is True:
|
||||
raise click.UsageError("--image cannot be combined with --remote builds.")
|
||||
@@ -1781,9 +1794,7 @@ def _call_host_backend_with_optional_tenant(
|
||||
prompted_for_tenant = True
|
||||
continue
|
||||
if err.status_code == 403 and "not enabled" in err.message.lower():
|
||||
smith_base = ControlPlaneEndpoints.from_control_plane_url(
|
||||
client.base_url
|
||||
).dashboard_url
|
||||
smith_base = client.endpoints.dashboard_url
|
||||
raise HostBackendError(
|
||||
"LangSmith Deployment is not enabled for this organization. "
|
||||
f"Enable it at {smith_base}/host/deployments"
|
||||
@@ -2144,6 +2155,7 @@ def _deploy_cmd(
|
||||
|
||||
secrets = _secrets_from_env(_env_without_deployment_name(env_vars))
|
||||
|
||||
selector = deployment_selector(deployment_id, name)
|
||||
source = _select_source(
|
||||
push_to=push_to,
|
||||
image=image,
|
||||
@@ -2151,10 +2163,10 @@ def _deploy_cmd(
|
||||
tag=tag,
|
||||
remote_build_flag=remote_build_flag,
|
||||
placement=RequestedPlacement(listener_id, k8s_namespace),
|
||||
selector=selector,
|
||||
)
|
||||
|
||||
client = _create_host_backend_client(host_url, api_key, env_vars=env_vars)
|
||||
endpoints = ControlPlaneEndpoints.from_control_plane_url(client.base_url)
|
||||
try:
|
||||
tracked_packages = find_tracked_packages(config, config_json) or None
|
||||
except Exception as exc:
|
||||
@@ -2164,7 +2176,7 @@ def _deploy_cmd(
|
||||
outcome = source.run(
|
||||
DeployContext(
|
||||
client=client,
|
||||
endpoints=endpoints,
|
||||
endpoints=client.endpoints,
|
||||
spec=BuildSpec(
|
||||
config=config,
|
||||
config_json=config_json,
|
||||
@@ -2176,7 +2188,7 @@ def _deploy_cmd(
|
||||
build_command=build_command,
|
||||
),
|
||||
verbose=verbose,
|
||||
selector=deployment_selector(deployment_id, name),
|
||||
selector=selector,
|
||||
deployment_type=deployment_type,
|
||||
secrets=secrets,
|
||||
tracked_packages=tracked_packages,
|
||||
@@ -2185,7 +2197,7 @@ def _deploy_cmd(
|
||||
dep_status_url = _emit_deployment_status_url(
|
||||
outcome.build_result.updated,
|
||||
outcome.deployment_id,
|
||||
endpoints,
|
||||
client.endpoints,
|
||||
)
|
||||
|
||||
if no_wait:
|
||||
|
||||
@@ -142,7 +142,8 @@ class HostBackendClient:
|
||||
}
|
||||
if tenant_id:
|
||||
headers["X-Tenant-ID"] = tenant_id
|
||||
self._base_url = base_url.rstrip("/")
|
||||
self._endpoints = ControlPlaneEndpoints.from_control_plane_url(base_url)
|
||||
self._base_url = self._endpoints.control_plane_url
|
||||
self._client = httpx.Client(
|
||||
base_url=self._base_url,
|
||||
headers=headers,
|
||||
@@ -154,6 +155,10 @@ class HostBackendClient:
|
||||
def base_url(self) -> str:
|
||||
return self._base_url
|
||||
|
||||
@property
|
||||
def endpoints(self) -> ControlPlaneEndpoints:
|
||||
return self._endpoints
|
||||
|
||||
def set_tenant(self, tenant_id: str) -> None:
|
||||
self._client.headers["X-Tenant-ID"] = tenant_id
|
||||
|
||||
|
||||
@@ -692,6 +692,7 @@ def test_push_to_places_a_new_deployment_on_the_only_listener(
|
||||
"listener_id": "listener-1",
|
||||
"listener_config": {"k8s_namespace": "agents"},
|
||||
}
|
||||
assert "Deploying through listener listener-1 in namespace agents" in result.output
|
||||
|
||||
|
||||
def test_push_to_places_a_new_deployment_on_the_chosen_listener(
|
||||
@@ -840,18 +841,6 @@ def test_listener_flags_are_refused_on_an_existing_deployment(
|
||||
assert deploy_project.docker.verbs() == []
|
||||
|
||||
|
||||
def test_automatic_placement_is_announced(deploy_project: DeployProject) -> None:
|
||||
deploy_project.control_plane.listeners = [LISTENER]
|
||||
|
||||
result = deploy_project.run(
|
||||
"--push-to", PUSH_REPOSITORY, host_url=CLOUD_CONTROL_PLANE_URL
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "listener-1" in result.output
|
||||
assert "agents" in result.output
|
||||
|
||||
|
||||
def test_a_deployment_without_a_listener_announces_nothing(
|
||||
deploy_project: DeployProject,
|
||||
) -> None:
|
||||
|
||||
@@ -13,6 +13,8 @@ import pytest
|
||||
|
||||
import langgraph_cli.deploy as deploy_mod
|
||||
from langgraph_cli.deploy import (
|
||||
ById,
|
||||
ByName,
|
||||
CustomerRegistrySource,
|
||||
DockerBuildCommand,
|
||||
ExistingDeployment,
|
||||
@@ -616,6 +618,7 @@ class TestSelectSource:
|
||||
"tag": None,
|
||||
"remote_build_flag": None,
|
||||
"placement": RequestedPlacement(),
|
||||
"selector": ByName("my-app"),
|
||||
}
|
||||
REPOSITORY = "registry.example.com/app"
|
||||
|
||||
@@ -628,7 +631,7 @@ class TestSelectSource:
|
||||
CustomerRegistrySource(
|
||||
reference=ImageReference(REPOSITORY, "latest"),
|
||||
prebuilt_image=None,
|
||||
placement=RequestedPlacement(),
|
||||
requested_placement=RequestedPlacement(),
|
||||
),
|
||||
id="push_to_selects_the_external_source_with_the_default_tag",
|
||||
),
|
||||
@@ -638,7 +641,7 @@ class TestSelectSource:
|
||||
CustomerRegistrySource(
|
||||
reference=ImageReference(REPOSITORY, "v2"),
|
||||
prebuilt_image=None,
|
||||
placement=RequestedPlacement(),
|
||||
requested_placement=RequestedPlacement(),
|
||||
),
|
||||
id="push_to_keeps_a_tag_given_in_the_reference",
|
||||
),
|
||||
@@ -648,7 +651,7 @@ class TestSelectSource:
|
||||
CustomerRegistrySource(
|
||||
reference=ImageReference(REPOSITORY, "v3"),
|
||||
prebuilt_image=None,
|
||||
placement=RequestedPlacement(),
|
||||
requested_placement=RequestedPlacement(),
|
||||
),
|
||||
id="tag_flag_composes_with_push_to",
|
||||
),
|
||||
@@ -658,7 +661,7 @@ class TestSelectSource:
|
||||
CustomerRegistrySource(
|
||||
reference=ImageReference(REPOSITORY, "latest"),
|
||||
prebuilt_image="app:dev",
|
||||
placement=RequestedPlacement(),
|
||||
requested_placement=RequestedPlacement(),
|
||||
),
|
||||
id="prebuilt_image_is_retagged_for_push_to_without_docker_checks",
|
||||
),
|
||||
@@ -671,7 +674,7 @@ class TestSelectSource:
|
||||
CustomerRegistrySource(
|
||||
reference=ImageReference(REPOSITORY, "latest"),
|
||||
prebuilt_image=None,
|
||||
placement=RequestedPlacement("listener-1", "agents"),
|
||||
requested_placement=RequestedPlacement("listener-1", "agents"),
|
||||
),
|
||||
id="push_to_carries_the_requested_placement",
|
||||
),
|
||||
@@ -1137,18 +1140,6 @@ def test_a_server_that_ignores_the_exact_name_filter_never_matches_another_deplo
|
||||
assert find_deployment_by_name(client, "brand-new-agent") is None
|
||||
|
||||
|
||||
def test_finding_a_deployment_by_name_returns_none_when_the_server_has_no_match():
|
||||
client = HostBackendClient(
|
||||
"https://api.example.com",
|
||||
"key",
|
||||
transport=httpx.MockTransport(
|
||||
lambda req: httpx.Response(200, json={"resources": []})
|
||||
),
|
||||
)
|
||||
|
||||
assert find_deployment_by_name(client, "agent") is None
|
||||
|
||||
|
||||
def test_a_full_page_without_a_match_refuses_to_claim_the_name_is_free():
|
||||
page = [
|
||||
{"id": f"dep-{index}", "name": f"other-agent-{index}"} for index in range(100)
|
||||
@@ -1189,3 +1180,23 @@ def test_a_partial_page_without_a_match_means_the_name_is_free():
|
||||
def test_a_listener_without_an_id_is_refused(resource):
|
||||
with pytest.raises(HostBackendError, match="without an id"):
|
||||
Listener.from_resource(resource)
|
||||
|
||||
|
||||
def test_a_deployment_id_with_listener_flags_is_refused_without_probing_docker(
|
||||
monkeypatch,
|
||||
):
|
||||
def explode() -> tuple[bool, str | None]:
|
||||
raise AssertionError("docker must not be probed for an argv-only conflict")
|
||||
|
||||
monkeypatch.setattr(deploy_mod, "can_build_locally", explode)
|
||||
|
||||
with pytest.raises(click.UsageError, match="--deployment-id"):
|
||||
_select_source(
|
||||
push_to="registry.example.com/app",
|
||||
image=None,
|
||||
image_name=None,
|
||||
tag=None,
|
||||
remote_build_flag=None,
|
||||
placement=RequestedPlacement(listener_id="listener-1"),
|
||||
selector=ById("dep-1"),
|
||||
)
|
||||
|
||||
@@ -79,18 +79,6 @@ def test_request_transport_error_raises():
|
||||
c._request("GET", "/test")
|
||||
|
||||
|
||||
def test_list_deployments_sends_query_params():
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
assert req.url.path == "/v2/deployments"
|
||||
assert req.url.params["name_contains"] == "my app"
|
||||
return httpx.Response(200, json={"ok": True})
|
||||
|
||||
c = HostBackendClient(
|
||||
"https://api.example.com", "test-key", transport=httpx.MockTransport(handler)
|
||||
)
|
||||
assert c.list_deployments(name_contains="my app") == []
|
||||
|
||||
|
||||
def _capturing_client(captured: dict) -> HostBackendClient:
|
||||
def handler(req: httpx.Request) -> httpx.Response:
|
||||
captured["body"] = req.read()
|
||||
|
||||
Reference in New Issue
Block a user